feat(io): autosave to .sv$ recovery files (SAVETIME command) (#205)
A periodic subscription (every SAVETIME minutes — default 10, 0 disables) writes a <name>.sv$ recovery copy of each dirty, already-saved drawing at its own DWG version. Non-destructive: it never touches the original file or the dirty flag, and skips unsaved (path-less) tabs. The SAVETIME command sets/queries the interval and persists it. Recovery files (.sv$ and .bak) open through the normal Open dialog — the loader detects DWG vs DXF from the leading magic bytes, and a "Backup / Autosave" filter lists them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
54186309b2
commit
02d2764e45
7 changed files with 93 additions and 6 deletions
|
|
@ -1501,6 +1501,27 @@ impl OpenCADStudio {
|
||||||
_ => self.command_line.push_error("Requires 0 or 1"),
|
_ => self.command_line.push_error("Requires 0 or 1"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"SAVETIME" => {
|
||||||
|
self.command_line
|
||||||
|
.push_output(&format!("SAVETIME = {}", self.savetime_min));
|
||||||
|
}
|
||||||
|
cmd if cmd.starts_with("SAVETIME ") => {
|
||||||
|
match cmd.trim_start_matches("SAVETIME").trim().parse::<i32>() {
|
||||||
|
Ok(v) if v >= 0 => {
|
||||||
|
self.savetime_min = v;
|
||||||
|
self.persist_settings_if_changed();
|
||||||
|
let msg = if v == 0 {
|
||||||
|
"SAVETIME set to 0 (autosave off)".to_string()
|
||||||
|
} else {
|
||||||
|
format!("SAVETIME set to {v} minute(s)")
|
||||||
|
};
|
||||||
|
self.command_line.push_output(&msg);
|
||||||
|
}
|
||||||
|
_ => self
|
||||||
|
.command_line
|
||||||
|
.push_error("Requires a non-negative number of minutes (0 = off)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
"PDSIZE" => {
|
"PDSIZE" => {
|
||||||
use crate::command::ValuePromptCommand;
|
use crate::command::ValuePromptCommand;
|
||||||
let c = ValuePromptCommand::new(
|
let c = ValuePromptCommand::new(
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,9 @@ pub(super) struct OpenCADStudio {
|
||||||
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file
|
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file
|
||||||
/// handler on each launch. Toggle with the FILEASSOC command.
|
/// handler on each launch. Toggle with the FILEASSOC command.
|
||||||
pub file_assoc_enabled: bool,
|
pub file_assoc_enabled: bool,
|
||||||
|
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
|
||||||
|
/// 0 disables autosave.
|
||||||
|
pub savetime_min: i32,
|
||||||
/// Persisted default viewport background, restored from settings and applied
|
/// Persisted default viewport background, restored from settings and applied
|
||||||
/// to every drawing tab (new and opened) so a chosen background survives
|
/// to every drawing tab (new and opened) so a chosen background survives
|
||||||
/// restarts (#188). `None` = the built-in dark-grey / off-white defaults.
|
/// restarts (#188). `None` = the built-in dark-grey / off-white defaults.
|
||||||
|
|
@ -1120,6 +1123,8 @@ pub enum Message {
|
||||||
OverwriteConfirm,
|
OverwriteConfirm,
|
||||||
/// Go back to the Save dialog from the overwrite warning.
|
/// Go back to the Save dialog from the overwrite warning.
|
||||||
OverwriteCancel,
|
OverwriteCancel,
|
||||||
|
/// Periodic autosave tick — write `.sv$` recovery files for dirty tabs.
|
||||||
|
AutoSave,
|
||||||
/// Save-as path picked for the unsaved-changes → save → close flow.
|
/// Save-as path picked for the unsaved-changes → save → close flow.
|
||||||
UnsavedPickedSavePath(Option<std::path::PathBuf>),
|
UnsavedPickedSavePath(Option<std::path::PathBuf>),
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -1870,6 +1875,7 @@ impl OpenCADStudio {
|
||||||
texteditmode: false,
|
texteditmode: false,
|
||||||
backup_on_save: true,
|
backup_on_save: true,
|
||||||
file_assoc_enabled: true,
|
file_assoc_enabled: true,
|
||||||
|
savetime_min: 10,
|
||||||
default_bg_color: None,
|
default_bg_color: None,
|
||||||
default_paper_bg_color: None,
|
default_paper_bg_color: None,
|
||||||
awaiting_vports: false,
|
awaiting_vports: false,
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,9 @@ pub struct UserSettings {
|
||||||
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
|
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
|
||||||
/// handler on every launch. Toggle with the FILEASSOC command.
|
/// handler on every launch. Toggle with the FILEASSOC command.
|
||||||
pub file_assoc_enabled: bool,
|
pub file_assoc_enabled: bool,
|
||||||
|
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
|
||||||
|
/// 0 disables autosave.
|
||||||
|
pub savetime_min: i32,
|
||||||
/// Persisted viewport background colours (0–255 RGB); `None` = app default
|
/// Persisted viewport background colours (0–255 RGB); `None` = app default
|
||||||
/// (dark grey model / off-white paper). Applied to every drawing tab on
|
/// (dark grey model / off-white paper). Applied to every drawing tab on
|
||||||
/// launch and to tabs opened later, so a chosen background survives restarts
|
/// launch and to tabs opened later, so a chosen background survives restarts
|
||||||
|
|
@ -141,6 +144,7 @@ impl Default for UserSettings {
|
||||||
texteditmode: false,
|
texteditmode: false,
|
||||||
backup_on_save: true,
|
backup_on_save: true,
|
||||||
file_assoc_enabled: true,
|
file_assoc_enabled: true,
|
||||||
|
savetime_min: 10,
|
||||||
bg_color: None,
|
bg_color: None,
|
||||||
paper_bg_color: None,
|
paper_bg_color: None,
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +191,11 @@ impl UserSettings {
|
||||||
}
|
}
|
||||||
"backup_on_save" => s.backup_on_save = val == "1",
|
"backup_on_save" => s.backup_on_save = val == "1",
|
||||||
"file_assoc_enabled" => s.file_assoc_enabled = val == "1",
|
"file_assoc_enabled" => s.file_assoc_enabled = val == "1",
|
||||||
|
"savetime_min" => {
|
||||||
|
if let Ok(v) = val.parse::<i32>() {
|
||||||
|
s.savetime_min = v.max(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
"disabled_plugins" => {
|
"disabled_plugins" => {
|
||||||
s.disabled_plugins = val
|
s.disabled_plugins = val
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|
@ -228,7 +237,7 @@ impl UserSettings {
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let body = format!(
|
let body = format!(
|
||||||
"dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbackup_on_save={}\nfile_assoc_enabled={}\nbg_color={}\npaper_bg_color={}\n",
|
"dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbackup_on_save={}\nfile_assoc_enabled={}\nsavetime_min={}\nbg_color={}\npaper_bg_color={}\n",
|
||||||
b(self.dyn_input),
|
b(self.dyn_input),
|
||||||
b(self.ortho),
|
b(self.ortho),
|
||||||
b(self.polar),
|
b(self.polar),
|
||||||
|
|
@ -242,6 +251,7 @@ impl UserSettings {
|
||||||
self.texteditmode,
|
self.texteditmode,
|
||||||
b(self.backup_on_save),
|
b(self.backup_on_save),
|
||||||
b(self.file_assoc_enabled),
|
b(self.file_assoc_enabled),
|
||||||
|
self.savetime_min,
|
||||||
rgb_to_str(self.bg_color),
|
rgb_to_str(self.bg_color),
|
||||||
rgb_to_str(self.paper_bg_color),
|
rgb_to_str(self.paper_bg_color),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ impl OpenCADStudio {
|
||||||
texteditmode: self.texteditmode,
|
texteditmode: self.texteditmode,
|
||||||
backup_on_save: self.backup_on_save,
|
backup_on_save: self.backup_on_save,
|
||||||
file_assoc_enabled: self.file_assoc_enabled,
|
file_assoc_enabled: self.file_assoc_enabled,
|
||||||
|
savetime_min: self.savetime_min,
|
||||||
bg_color: self.default_bg_color.map(f4_to_u3),
|
bg_color: self.default_bg_color.map(f4_to_u3),
|
||||||
paper_bg_color: self.default_paper_bg_color.map(f4_to_u3),
|
paper_bg_color: self.default_paper_bg_color.map(f4_to_u3),
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +63,7 @@ impl OpenCADStudio {
|
||||||
self.texteditmode = s.texteditmode;
|
self.texteditmode = s.texteditmode;
|
||||||
self.backup_on_save = s.backup_on_save;
|
self.backup_on_save = s.backup_on_save;
|
||||||
self.file_assoc_enabled = s.file_assoc_enabled;
|
self.file_assoc_enabled = s.file_assoc_enabled;
|
||||||
|
self.savetime_min = s.savetime_min;
|
||||||
self.default_bg_color = s.bg_color.map(u3_to_f4);
|
self.default_bg_color = s.bg_color.map(u3_to_f4);
|
||||||
self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4);
|
self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4);
|
||||||
// Push the restored background onto every drawing tab that exists now
|
// Push the restored background onto every drawing tab that exists now
|
||||||
|
|
@ -675,6 +677,41 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||||
self.on_save_dialog_confirm()
|
self.on_save_dialog_confirm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Periodic autosave (SAVETIME): write a `.sv$` recovery copy for every
|
||||||
|
/// dirty tab that has a path, at the document's own DWG version. Best-effort
|
||||||
|
/// and non-destructive — it never touches the original file or the dirty
|
||||||
|
/// flag, and skips unsaved (path-less) tabs.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub(super) fn on_autosave(&mut self) -> Task<Message> {
|
||||||
|
let mut n = 0;
|
||||||
|
for i in 0..self.tabs.len() {
|
||||||
|
if !self.tabs[i].dirty {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(path) = self.tabs[i].current_path.clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let version = self.tabs[i].scene.document.version;
|
||||||
|
if let Ok(bytes) =
|
||||||
|
crate::io::save_to_bytes(&self.tabs[i].scene.document, "dwg", version)
|
||||||
|
{
|
||||||
|
if std::fs::write(path.with_extension("sv$"), bytes).is_ok() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
self.command_line
|
||||||
|
.push_output(&format!("Autosaved {n} drawing(s) to .sv$"));
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub(super) fn on_autosave(&mut self) -> Task<Message> {
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn on_page_setup_commit(&mut self) -> Task<Message> {
|
pub(super) fn on_page_setup_commit(&mut self) -> Task<Message> {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let layout_name = self.tabs[i].scene.current_layout.clone();
|
let layout_name = self.tabs[i].scene.current_layout.clone();
|
||||||
|
|
|
||||||
|
|
@ -2540,6 +2540,7 @@ impl OpenCADStudio {
|
||||||
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
|
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::AutoSave => self.on_autosave(),
|
||||||
|
|
||||||
Message::UnsavedPickedSavePath(Some(path)) => {
|
Message::UnsavedPickedSavePath(Some(path)) => {
|
||||||
self.on_unsaved_picked_save_path_some(path)
|
self.on_unsaved_picked_save_path_some(path)
|
||||||
|
|
|
||||||
|
|
@ -1423,6 +1423,17 @@ impl OpenCADStudio {
|
||||||
iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts);
|
iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts);
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let web_fonts = Subscription::none();
|
let web_fonts = Subscription::none();
|
||||||
|
// Periodic autosave to a `.sv$` recovery file (native only). SAVETIME is
|
||||||
|
// the interval in minutes; 0 disables it.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let autosave = if self.savetime_min > 0 {
|
||||||
|
iced::time::every(std::time::Duration::from_secs(self.savetime_min as u64 * 60))
|
||||||
|
.map(|_| Message::AutoSave)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
};
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let autosave = Subscription::none();
|
||||||
iced::Subscription::batch([
|
iced::Subscription::batch([
|
||||||
frames,
|
frames,
|
||||||
history_tick,
|
history_tick,
|
||||||
|
|
@ -1430,6 +1441,7 @@ impl OpenCADStudio {
|
||||||
hover_dwell,
|
hover_dwell,
|
||||||
caret_blink,
|
caret_blink,
|
||||||
web_fonts,
|
web_fonts,
|
||||||
|
autosave,
|
||||||
event::listen_with(|ev, status, win_id| {
|
event::listen_with(|ev, status, win_id| {
|
||||||
use iced::event::Status;
|
use iced::event::Status;
|
||||||
match ev {
|
match ev {
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,10 @@ const PHASE_FINALIZING: u8 = 3;
|
||||||
pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
|
pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
|
||||||
let handle = rfd::AsyncFileDialog::new()
|
let handle = rfd::AsyncFileDialog::new()
|
||||||
.set_title("Open CAD file")
|
.set_title("Open CAD file")
|
||||||
.add_filter("CAD Files", &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"])
|
.add_filter("CAD Files", &["dwg", "dxf", "bak", "sv$", "DWG", "DXF", "BAK"])
|
||||||
.add_filter("DWG Files", &["dwg", "DWG"])
|
.add_filter("DWG Files", &["dwg", "DWG"])
|
||||||
.add_filter("DXF Files", &["dxf", "DXF"])
|
.add_filter("DXF Files", &["dxf", "DXF"])
|
||||||
.add_filter("Backup Files", &["bak", "BAK"])
|
.add_filter("Backup / Autosave", &["bak", "sv$", "BAK"])
|
||||||
.add_filter("All Files", &["*"])
|
.add_filter("All Files", &["*"])
|
||||||
.pick_file()
|
.pick_file()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -169,9 +169,9 @@ pub fn load_file(path: &Path) -> Result<CadDocument, String> {
|
||||||
.map(|e| e.to_string_lossy().to_lowercase())
|
.map(|e| e.to_string_lossy().to_lowercase())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// A `.bak` backup holds a verbatim DWG or DXF copy — detect the real format
|
// A `.bak` backup or `.sv$` autosave holds a verbatim DWG/DXF copy — detect
|
||||||
// from the file's leading bytes rather than the `.bak` extension.
|
// the real format from the file's leading bytes, not the extension.
|
||||||
let effective = if ext == "bak" {
|
let effective = if ext == "bak" || ext == "sv$" {
|
||||||
sniff_dwg_or_dxf(path)
|
sniff_dwg_or_dxf(path)
|
||||||
} else {
|
} else {
|
||||||
ext.clone()
|
ext.clone()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue