From 975067b44a76d97f3bb4275f8b1a9c83301e44ef Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 13:17:59 +0700 Subject: [PATCH 1/2] M4.37: Data Recovery keeps copies of modified documents and offers them after a crash - Every Preferences > File Handling > Data Recovery interval, modified documents changed since their last copy are written to the recovery store in the background (the same snapshot and encoder as Background Save; the status bar shows it). Complex documents (over 20,000 objects) are skipped while Turn off Data Recovery for complex documents is on. - A copy goes when its document is saved, reverted, closed or undone back to its saved state, and when the app quits with nothing unsaved. - Each running app keeps its copies in an area of its own: a sub-folder whose .lock file it keeps locked for its lifetime (File::try_lock), or on the web a heartbeat refreshed every minute. Only areas nobody holds (lock free, or no heartbeat for three intervals, at least three minutes) are offered, and an area is held while it is restored or discarded, so a second running instance never offers or deletes a live instance's copies. Empty leftover areas are tidied away. - The next launch after a crash asks with the Recover Documents dialog (Restore / Discard / Cancel): restored documents open as " [Recovered]", unsaved, and Save asks where to save them, suggesting their original file; the copy moves into the new app's area. - Store: the recoveryFolder preference, else Data Recovery beside the desktop app's preferences; browser storage on the web (Services::recovery_store); tests use an in-memory store (with in-memory locks, or heartbeats). - Engine: file.recovery.save, file.recovery.list, file.recovery.restore {file?}, file.recovery.discard {file?}; DocState.recovered and DocState.recovery. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/vectorcraft-web/Cargo.toml | 3 +- apps/vectorcraft-web/src/web.rs | 43 ++ apps/vectorcraft/src/main.rs | 6 + crates/engine/src/cmd/fileio/mod.rs | 3 +- crates/engine/src/cmd/fileio/save.rs | 66 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/recovery.rs | 840 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 27 +- crates/engine/src/tests_recovery.rs | 361 +++++++++++ crates/engine/tests/command_sweep.rs | 35 ++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/recovery.rs | 37 ++ crates/ui-egui/src/io.rs | 4 +- crates/ui-egui/src/lib.rs | 21 +- crates/ui-egui/src/panels/links.rs | 2 +- crates/ui-egui/src/recovery.rs | 135 ++++ crates/ui-egui/src/tests_recovery.rs | 178 ++++++ crates/ui-egui/src/unsaved.rs | 2 + docs/control-protocol.md | 7 + docs/mcp.md | 26 + 21 files changed, 1777 insertions(+), 24 deletions(-) create mode 100644 crates/engine/src/cmd/recovery.rs create mode 100644 crates/engine/src/tests_recovery.rs create mode 100644 crates/ui-egui/src/dialogs/recovery.rs create mode 100644 crates/ui-egui/src/recovery.rs create mode 100644 crates/ui-egui/src/tests_recovery.rs diff --git a/Cargo.lock b/Cargo.lock index ea3394025..f87398f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4533,6 +4533,7 @@ dependencies = [ "log", "rfd", "vectorcraft-engine", + "vectorcraft-format", "vectorcraft-ui-egui", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index c3e927588..cedc22825 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] vectorcraft-engine = { workspace = true } vectorcraft-ui-egui = { workspace = true } +vectorcraft-format = { workspace = true } egui = { workspace = true } log = { workspace = true } @@ -26,4 +27,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index d3f966d73..4dc0bb27a 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex}; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::cmd::recovery::RecoveryStore; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; @@ -172,10 +173,52 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic } })), inbox: Some(inbox), + recovery_store: Some(Arc::new(BrowserStore)), ..Default::default() } } +/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by +/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`/`. It has no +/// locks: each tab holds its area with a heartbeat, judged by the browser's clock. +struct BrowserStore; + +const RECOVERY_PREFIX: &str = "vectorcraft-recovery/"; + +fn js_err(e: wasm_bindgen::JsValue) -> String { + format!("{e:?}") +} + +fn local_storage() -> Result { + web_sys::window().ok_or("no window")?.local_storage().map_err(js_err)?.ok_or_else(|| "browser storage is turned off".into()) +} + +impl RecoveryStore for BrowserStore { + fn list(&self) -> Result, String> { + let s = local_storage()?; + let n = s.length().map_err(js_err)?; + Ok((0..n).filter_map(|i| s.key(i).ok().flatten()).filter_map(|k| k.strip_prefix(RECOVERY_PREFIX).map(str::to_string)).collect()) + } + fn read(&self, name: &str) -> Result, String> { + let text = local_storage()?.get_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)?; + text.and_then(|t| vectorcraft_format::base64_decode(&t)).ok_or_else(|| format!("{name}: no such recovery entry")) + } + fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + local_storage()? + .set_item(&format!("{RECOVERY_PREFIX}{name}"), &vectorcraft_format::base64_encode(bytes)) + .map_err(|e| format!("browser storage is full or turned off: {}", js_err(e))) + } + fn remove(&self, name: &str) -> Result<(), String> { + local_storage()?.remove_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err) + } + fn location(&self) -> String { + "browser storage".into() + } + fn now(&self) -> Option { + Some((js_sys::Date::now() / 1000.0) as i64) + } +} + /// Trigger a browser download of `bytes` named after the last component of `path`. fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index db1aed4ef..b544c55fc 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -252,6 +252,12 @@ fn main() -> eframe::Result { app.session.swatch_libraries.set_user_dir(swatches); let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string())); app.session.style_libraries.set_user_dir(styles); + // Data Recovery copies live next to the preferences too (none for runs without + // preferences, such as agents' test runs, unless the recoveryFolder preference is set). + if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() { + let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string())); + app.session.recovery.set_default_folder(recovery); + } app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = CUSTOM_TITLEBAR; if let Some(port) = control_port { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 756e74368..52b7587cd 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -47,6 +47,7 @@ pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; +pub(crate) use save::job_for; pub use save::{ SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, templates_folder, @@ -648,7 +649,7 @@ fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document } /// `a` with the fields of `b`. -fn merge(mut a: Value, b: Value) -> Value { +pub(crate) fn merge(mut a: Value, b: Value) -> Value { if let (Some(a), Value::Object(b)) = (a.as_object_mut(), b) { a.extend(b); } diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 007399955..cb6d284d9 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -67,7 +67,7 @@ impl SaveMode { pub struct SavePlan { pub mode: SaveMode, /// Where to write. `None` when no path is known (never saved, converted from an older version, - /// or a format other than the document's own): the caller asks for one or takes the bytes. + /// restored by Data Recovery, or a format other than the document's own): the caller asks for one or takes the bytes. pub path: Option, pub format: &'static Format, /// The format's options (only those its encoder reads). @@ -178,7 +178,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { // None given: the ones the document was last saved with in this format. let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given }; // Save writes the document's own file; the other modes only where they are told to. - let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && format.id == st.format).then(|| st.path.clone()).flatten()); + let path = + path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); let ext = format.extensions[0]; let name = match mode { @@ -255,16 +256,20 @@ pub struct SaveJob { /// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the /// document's own). Bad options fail here, before anything is encoded. pub fn save_job(s: &mut Session, plan: SavePlan) -> Result { - let cmd = plan.mode.command(); - let retargets = plan.retargets(); // The web build, without a clock, leaves the dates unless given one. if plan.path.is_some() - && retargets + && plan.retargets() && let Some(at) = clock_date(s, "modified", plan.modified) { stamp_save_dates(s.doc_mut()?, at); } - let st = s.doc()?; + job_for(s, s.doc()?, plan) +} + +/// Snapshot `st` (any open document of `s`) for `plan`, without stamping dates (Data Recovery +/// snapshots documents that aren't active). +pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result { + let cmd = plan.mode.command(); let own = doc_to_save(st, plan.format); // A native file records its links' paths relative to where it is written. let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p)); @@ -285,7 +290,7 @@ pub fn save_job(s: &mut Session, plan: SavePlan) -> Result { // A PDF of every artboard with the PDF options, always carrying the native document. o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); } - Ok(SaveJob { plan, doc, params, snapshot: s.doc()?.doc.clone() }) + Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone() }) } impl SaveJob { @@ -293,10 +298,14 @@ impl SaveJob { &self.plan } - /// Encode the file and write it with `write` (the file, then the images an SVG links to, - /// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: - /// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`. - pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { + /// The document when the job was made (what counts as saved once the file is written). + pub(crate) fn snapshot(&self) -> &Arc { + &self.snapshot + } + + /// Encode the file → it, with the warnings (what the format loses first, then the encoder's + /// own notes: PDF options not applied yet…). + pub fn encode(&self) -> Result { let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc); let mut enc = if plan.format.id == "ai" { let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?; @@ -307,9 +316,17 @@ impl SaveJob { if enc.files.len() != 1 { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } - // What the format loses first, then the encoder's own notes (PDF options not applied yet…). - let mut warnings = std::mem::take(&mut enc.warnings); - warnings.splice(0..0, fidelity_warning(plan.format)); + enc.warnings.splice(0..0, fidelity_warning(plan.format)); + Ok(enc) + } + + /// Encode the file and write it with `write` (the file, then the images an SVG links to, + /// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: + /// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`. + pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { + let (plan, doc) = (&self.plan, &*self.doc); + let mut enc = self.encode()?; + let warnings = std::mem::take(&mut enc.warnings); let extra = json!({ "format": plan.format.id, "warnings": warnings }); let Some(path) = &plan.path else { let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; @@ -341,17 +358,31 @@ impl SaveJob { st.format = self.plan.format.id; st.save_options = self.plan.options; st.converted = false; + st.recovered = false; st.mark_saved_as(&self.snapshot); } } + + /// [`SaveJob::finish`] for document `uid` of `s`, if it is still open; a file that became + /// its own also ends its Data Recovery copy. + pub fn complete(self, s: &mut Session, uid: u64) { + let saved = self.plan.retargets() && self.plan.path.is_some(); + if let Some(st) = s.document_mut(uid) { + self.finish(st); + } + if saved { + crate::cmd::recovery::forget(s, uid); + } + } } /// Encode `plan` and write it with `write`, then make the file the document's own (see /// [`SaveJob`]). Without a path the document is unchanged. pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let job = save_job(s, plan)?; + let uid = s.doc()?.uid; let out = job.write(write)?; - job.finish(s.doc_mut()?); + job.complete(s, uid); Ok(out) } @@ -377,6 +408,9 @@ fn revert(s: &mut Session, _: &Value) -> Result { let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?; doc.template = false; + // Back to the saved file: its recovery copy has nothing left to recover. + let uid = s.doc()?.uid; + crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); Ok(json!({ "path": path })) } @@ -409,7 +443,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b0bdffbc4..a56a048e7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -47,6 +47,7 @@ pub mod prefscmds; pub mod print; pub mod rasterfx; mod recolor; +pub mod recovery; mod select; pub(crate) mod slices; mod stroke; @@ -230,6 +231,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(package::specs()); v.extend(slices::specs()); v.extend(print::specs()); + v.extend(recovery::specs()); v }) } diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs new file mode 100644 index 000000000..f3451e236 --- /dev/null +++ b/crates/engine/src/cmd/recovery.rs @@ -0,0 +1,840 @@ +//! Data Recovery: copies of modified documents kept in a recovery store, so a crash loses at most +//! the last few minutes of work. +//! +//! - `file.recovery.save` writes a copy of every modified document changed since its last copy +//! (the app runs it every Preferences → File Handling → Data Recovery interval, in the +//! background like Background Save: [`jobs`] snapshots, [`RecoveryJob::write`] encodes and +//! stores, [`RecoveryJob::finish`] records the copy). +//! - A copy is removed when its document is saved, reverted or closed ([`forget`]), so copies +//! left in the store after the app has quit are what a crash left behind: +//! `file.recovery.list` lists them, `file.recovery.restore` opens them as +//! " [Recovered]" (modified, never saved, the original path remembered for Save As) and +//! `file.recovery.discard` deletes them. +//! +//! Every running app (each browser tab) keeps its copies in an area of its own, `/…`, and +//! holds it while it runs: a lock on the area for its lifetime where the store has locks (folders: +//! [`FolderStore`]), else a heartbeat it refreshes ([`heartbeat`]; browser storage). Only areas +//! nobody holds any more (their app is gone) are offered, and while their copies are restored or +//! discarded the area is held, so two apps launched together never both take it. +//! +//! Each copy is two entries: `/.vectorcraft` (the native document, written as a save +//! writes it) and `/.json` (its title, original path and format, and when it was +//! written). The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder` +//! preference, else the folder the app sets), browser storage on the web, or any +//! [`RecoveryStore`] the host installs (tests use [`MemoryStore`]). + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_doc::Document; + +use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan}; +use super::*; +use crate::DocState; + +/// Documents with more objects than this are complex: with Preferences → Turn off Data Recovery +/// for complex documents, they get no recovery copy. +pub const COMPLEX_OBJECTS: usize = 20_000; + +/// The extension of a copy's document entry. +const DOC_EXT: &str = ".vectorcraft"; +/// The extension of a copy's details entry. +const META_EXT: &str = ".json"; +/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix +/// seconds, as text). +const BEAT: &str = "heartbeat"; +/// The least time without a heartbeat after which an area's app counts as gone (seconds); else +/// three Data Recovery intervals. +const MIN_STALE: i64 = 180; +/// How often a running app refreshes its heartbeat (seconds). +pub const HEARTBEAT_EVERY: f64 = 60.0; + +/// Holds an area while it lives (dropping it releases the lock). +pub type Hold = Box; + +/// What [`RecoveryStore::lock`] got. +pub enum Lock { + /// The area is ours until the hold is dropped. + Held(Hold), + /// A running app holds it. + Busy, + /// The store has no locks: areas are held with heartbeats. + Unsupported, +} + +/// Where recovery copies are kept: named entries of bytes, `/`. Called from the UI +/// thread and the background worker. +pub trait RecoveryStore: Send + Sync { + /// The names of the entries (`/`). + fn list(&self) -> std::result::Result, String>; + fn read(&self, name: &str) -> std::result::Result, String>; + /// Create or replace an entry (a failed write must not leave a damaged entry behind). + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String>; + /// Delete an entry (one that isn't there is no error). + fn remove(&self, name: &str) -> std::result::Result<(), String>; + /// Where the entries are, as the user knows it (a folder, "browser storage"). + fn location(&self) -> String; + /// The areas in the store (default: those with entries). + fn areas(&self) -> std::result::Result, String> { + let names = self.list()?; + let set: BTreeSet = names.iter().filter_map(|n| n.split_once('/')).map(|(a, _)| a.to_string()).collect(); + Ok(set.into_iter().collect()) + } + /// Hold area `area` exclusively, if the store has locks (default: it hasn't). + fn lock(&self, _area: &str) -> std::result::Result { + Ok(Lock::Unsupported) + } + /// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder). + fn remove_area(&self, _area: &str) {} + /// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone). + fn now(&self) -> Option { + vectorcraft_doc::metadata::now_unix() + } +} + +/// A store in memory (tests, and hosts without storage). Areas are locked in memory, so sessions +/// sharing one store behave like apps sharing a folder; [`MemoryStore::without_locks`] uses +/// heartbeats like browser storage, judged by a clock tests set ([`MemoryStore::set_now`]). +pub struct MemoryStore { + entries: Mutex>>, + held: Arc>>, + locks: bool, + now: Mutex>, +} + +impl Default for MemoryStore { + fn default() -> Self { + Self { entries: Default::default(), held: Default::default(), locks: true, now: Mutex::new(Some(1_000_000)) } + } +} + +impl MemoryStore { + /// A store without locks: areas are held with heartbeats. + pub fn without_locks() -> Self { + Self { locks: false, ..Self::default() } + } + + /// Set the clock heartbeats are judged by. + pub fn set_now(&self, t: i64) { + *self.now.lock().unwrap_or_else(|e| e.into_inner()) = Some(t); + } + + fn entries(&self) -> std::sync::MutexGuard<'_, BTreeMap>> { + self.entries.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +/// An area of a [`MemoryStore`] held until dropped. +struct MemoryHold { + held: Arc>>, + area: String, +} + +impl Drop for MemoryHold { + fn drop(&mut self) { + self.held.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.area); + } +} + +impl RecoveryStore for MemoryStore { + fn list(&self) -> std::result::Result, String> { + Ok(self.entries().keys().cloned().collect()) + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.entries().get(name).cloned().ok_or_else(|| format!("{name}: no such recovery entry")) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.entries().insert(name.to_string(), bytes.to_vec()); + Ok(()) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.entries().remove(name); + Ok(()) + } + fn location(&self) -> String { + "memory".into() + } + fn lock(&self, area: &str) -> std::result::Result { + if !self.locks { + return Ok(Lock::Unsupported); + } + if !self.held.lock().unwrap_or_else(|e| e.into_inner()).insert(area.to_string()) { + return Ok(Lock::Busy); + } + Ok(Lock::Held(Box::new(MemoryHold { held: self.held.clone(), area: area.to_string() }))) + } + fn now(&self) -> Option { + *self.now.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +/// A folder of files (desktop): an area is a sub-folder whose `.lock` file its app keeps locked +/// while it runs. Created when the first copy is written. +#[cfg(not(target_arch = "wasm32"))] +pub struct FolderStore(std::path::PathBuf); + +/// The lock file of a [`FolderStore`] area. +#[cfg(not(target_arch = "wasm32"))] +const LOCK_FILE: &str = ".lock"; + +#[cfg(not(target_arch = "wasm32"))] +impl FolderStore { + pub fn new(folder: impl Into) -> Self { + Self(folder.into()) + } + + /// Is `part` a plain file or folder name (no separators, not hidden)? + fn plain(part: &str) -> bool { + !part.starts_with('.') && std::path::Path::new(part).file_name().is_some_and(|f| f == part) + } + + /// The folder of area `area`. + fn area(&self, area: &str) -> std::result::Result { + if !Self::plain(area) { + return Err(format!("`{area}` is no recovery area name")); + } + Ok(self.0.join(area)) + } + + /// The file of entry `name` (`/`: anything else is refused). + fn file(&self, name: &str) -> std::result::Result { + match name.split_once('/') { + Some((area, file)) if Self::plain(file) => Ok(self.area(area)?.join(file)), + _ => Err(format!("`{name}` is no recovery entry name")), + } + } + + /// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist. + fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result, String> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(format!("{}: {e}", dir.display())), + }; + Ok(entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| if dirs { t.is_dir() } else { t.is_file() })) + .filter_map(|e| e.file_name().to_str().map(str::to_string)) + // Lock files, and files left by an interrupted atomic write. + .filter(|n| Self::plain(n)) + .collect()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl RecoveryStore for FolderStore { + fn list(&self) -> std::result::Result, String> { + let mut out = vec![]; + for area in self.areas()? { + out.extend(Self::names(&self.0.join(&area), false)?.into_iter().map(|f| format!("{area}/{f}"))); + } + Ok(out) + } + fn read(&self, name: &str) -> std::result::Result, String> { + let f = self.file(name)?; + std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display())) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + let f = self.file(name)?; + if let Some(dir) = f.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display())) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + let f = self.file(name)?; + match std::fs::remove_file(&f) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())), + _ => Ok(()), + } + } + fn location(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn areas(&self) -> std::result::Result, String> { + Self::names(&self.0, true) + } + fn lock(&self, area: &str) -> std::result::Result { + let dir = self.area(area)?; + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let path = dir.join(LOCK_FILE); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(|e| format!("{}: {e}", path.display()))?; + match file.try_lock() { + Ok(()) => Ok(Lock::Held(Box::new(file))), + Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), + Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), + } + } + fn remove_area(&self, area: &str) { + // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty + // (copies came back meanwhile) stays. + if let Ok(dir) = self.area(area) { + let _ = std::fs::remove_file(dir.join(LOCK_FILE)); + let _ = std::fs::remove_dir(&dir); + } + } +} + +/// The area this session keeps its copies in, while it holds it. +struct Own { + /// The store it is in (copies are removed from there even after the preference moves on). + store: Arc, + area: String, + /// The lock held for the session's lifetime; none in stores with heartbeats. + hold: Option, +} + +/// The session's recovery store settings and its own area ([`crate::Session::recovery`]). +#[derive(Default)] +pub struct Recovery { + /// A store the host installed (the web's browser storage, tests): wins over folders. + store: Option>, + /// The folder the desktop app keeps copies in when the `recoveryFolder` preference is empty. + default_folder: Option, + own: Option, + /// Gone apps' areas this session took over by heartbeat (stores without locks), so its own + /// fresh heartbeat there doesn't make them look running. + adopted: BTreeSet, +} + +impl Recovery { + /// Keep copies in `store` (instead of a folder). + pub fn set_store(&mut self, store: Arc) { + self.store = Some(store); + } + + /// The folder copies go to while the `recoveryFolder` preference is empty (desktop; `None`: + /// none, so Data Recovery is off until the preference names one). + pub fn set_default_folder(&mut self, folder: Option) { + self.default_folder = folder; + } + + /// This session's area, once it has written a copy. + pub fn own_area(&self) -> Option<&str> { + self.own.as_ref().map(|o| o.area.as_str()) + } +} + +/// The store copies go to now (see [`Recovery`]), if any. +pub fn store(s: &Session) -> Option> { + if let Some(st) = &s.recovery.store { + return Some(st.clone()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let folder = Some(s.prefs.recovery_folder.trim()).filter(|f| !f.is_empty()).map(str::to_string).or_else(|| s.recovery.default_folder.clone()); + folder.map(|f| Arc::new(FolderStore::new(f)) as Arc) + } + #[cfg(target_arch = "wasm32")] + None +} + +fn has_store(s: &Session) -> std::result::Result<(), String> { + match store(s) { + Some(_) => Ok(()), + None => Err("Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)".into()), + } +} + +fn need_store(s: &Session, cmd: &str) -> Result> { + store(s).ok_or_else(|| bad(cmd, has_store(s).err().unwrap_or_default())) +} + +/// Seconds without a heartbeat after which an area's app counts as gone: three Data Recovery +/// intervals, at least [`MIN_STALE`]. +fn stale_after(s: &Session) -> i64 { + (i64::from(s.prefs.autosave_interval.max(1)) * 180).max(MIN_STALE) +} + +/// Record that `area`'s app runs (stores without locks). +fn beat(store: &dyn RecoveryStore, area: &str) -> std::result::Result<(), String> { + let now = store.now().ok_or("no clock to keep a heartbeat by")?; + store.write(&format!("{area}/{BEAT}"), now.to_string().as_bytes()) +} + +/// Has `area`'s app said it runs within `stale` seconds (stores without locks)? Without a clock +/// every app counts as running. +fn beating(store: &dyn RecoveryStore, area: &str, stale: i64) -> bool { + let Some(now) = store.now() else { return true }; + let at = store.read(&format!("{area}/{BEAT}")).ok().and_then(|b| String::from_utf8(b).ok()).and_then(|t| t.trim().parse::().ok()); + at.is_some_and(|t| now.saturating_sub(t) <= stale) +} + +/// Refresh this session's heartbeat in a store without locks (the app does this every +/// [`HEARTBEAT_EVERY`] seconds). Best effort: a missed beat only lets another app offer the copies +/// once three intervals have passed. +pub fn heartbeat(s: &Session) { + if let Some(own) = s.recovery.own.as_ref().filter(|o| o.hold.is_none()) { + let _ = beat(own.store.as_ref(), &own.area); + } +} + +/// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the +/// session's lifetime (locked, or with a heartbeat). +pub fn claim(s: &mut Session, store: &Arc) -> Result { + if let Some(own) = &s.recovery.own { + if own.store.location() == store.location() { + return Ok(own.area.clone()); + } + // The store moved (the preference names another folder): copies start again there. + release(s); + } + let taken = store.areas().map_err(EngineError::Other)?; + let base = store.now().unwrap_or(0); + for n in 1..=1000 { + let area = format!("{base}-{n}"); + if taken.contains(&area) { + continue; + } + let hold = match store.lock(&area).map_err(EngineError::Other)? { + Lock::Held(h) => Some(h), + Lock::Busy => continue, + Lock::Unsupported => { + beat(store.as_ref(), &area).map_err(EngineError::Other)?; + None + } + }; + s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold }); + return Ok(area); + } + Err(EngineError::Other("no free recovery area".into())) +} + +/// Give up this session's area: its copies, heartbeat and lock go (quitting with nothing unsaved). +pub fn release(s: &mut Session) { + for st in &mut s.docs { + st.recovery = None; + } + let Some(Own { store, area, hold }) = s.recovery.own.take() else { return }; + let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area); + for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { + let _ = store.remove(name); + } + drop(hold); + store.remove_area(&area); +} + +/// A document's recovery copy: its entry and the document it holds. +#[derive(Clone, Debug)] +pub struct RecoveryCopy { + /// The copy's name in the store (`/`, without extension). + pub file: String, + /// The document as last copied (unchanged since: no new copy needed). + pub doc: Arc, +} + +/// What a copy's details entry says. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +struct Meta { + title: String, + /// The document's file when it was copied, if it had one. + path: Option, + /// The format Save writes for it ([`SAVE_FORMATS`]). + format: String, + /// When the copy was written (Unix seconds; none without a clock). + saved: Option, +} + +fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { + store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() +} + +/// A copy of one document, snapshotted and ready to encode and store anywhere (the background +/// worker too): [`jobs`], then [`RecoveryJob::write`], then [`RecoveryJob::finish`]. +#[derive(Clone)] +pub struct RecoveryJob { + /// The document's [`DocState::uid`]. + pub uid: u64, + /// The copy's name in the store (`/`). + pub file: String, + /// The document's title (status messages). + pub title: String, + meta: Meta, + save: SaveJob, +} + +impl RecoveryJob { + /// Encode the snapshot and store it with its details → `{file, title, bytes}`. + pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result { + let enc = self.save.encode().map_err(|e| e.to_string())?; + let bytes = enc.files.first().map(|f| f.1.as_slice()).ok_or("nothing encoded")?; + let meta = serde_json::to_vec_pretty(&self.meta).map_err(|e| e.to_string())?; + store.write(&format!("{}{DOC_EXT}", self.file), bytes)?; + store.write(&format!("{}{META_EXT}", self.file), &meta)?; + Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len() })) + } + + /// Once stored: the document (if still open) remembers the copy, so it isn't written again + /// while unchanged and goes when the document is saved or closed. A document saved or closed + /// meanwhile has nothing left to recover: its copy goes at once. + pub fn finish(self, s: &mut Session) { + let snapshot = self.save.snapshot().clone(); + let copy = RecoveryCopy { file: self.file.clone(), doc: snapshot }; + match s.document_mut(self.uid).filter(|st| st.is_dirty()) { + Some(st) => st.recovery = Some(copy), + None => remove_copy(s, &self.file), + } + } +} + +/// How many objects `doc` has (its complexity). +fn object_count(doc: &Document) -> usize { + doc.layers.iter().map(|l| l.count()).sum() +} + +/// Why document `st` gets no recovery copy now, if it doesn't: unchanged since its last copy (or +/// never changed), or complex while the preference turns Data Recovery off for those. +pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> { + if !st.is_dirty() { + return Some("no unsaved changes"); + } + if st.recovery.as_ref().is_some_and(|c| Arc::ptr_eq(&c.doc, &st.doc)) { + return Some("unchanged since its last recovery copy"); + } + if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS { + return Some("complex document (Data Recovery is off for complex documents)"); + } + None +} + +/// A file name part from a title: letters, digits, `-`, `_` and spaces (others as `-`). +fn safe_stem(title: &str) -> String { + let stem: String = fileio::file_stem(title).chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ') { c } else { '-' }).collect(); + let stem = stem.trim().chars().take(60).collect::(); + if stem.is_empty() { "Untitled".into() } else { stem } +} + +/// The copies among store entries `names`, each once, as `(area, "/")`. +fn copies_in(names: &[String]) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = + names.iter().filter_map(|n| n.strip_suffix(DOC_EXT)).filter_map(|f| f.split_once('/').map(|(a, _)| (a.to_string(), f.to_string()))).collect(); + v.sort(); + v.dedup(); + v +} + +/// A copy name `/-` none of `taken` has. +fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String { + (1..=10_000).map(|n| format!("{area}/{stem}-{n}")).find(|f| !taken.contains(f)).unwrap_or_else(|| format!("{area}/{stem}-x")) +} + +/// The jobs for every document that needs a copy now, in this session's area of `store` (taken +/// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified +/// documents that get none). +pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, Vec)> { + let skipped: Vec = s + .documents() + .iter() + .filter(|st| st.is_dirty()) + .filter_map(|st| skip_reason(s, st).map(|why| json!({ "title": st.title(), "reason": why }))) + .collect(); + if s.documents().iter().all(|st| skip_reason(s, st).is_some()) { + return Ok((vec![], skipped)); + } + let area = claim(s, store)?; + let mut taken: Vec = + copies_in(&store.list().map_err(EngineError::Other)?).into_iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect(); + taken.extend(s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone())); + let native = fileio::format("vectorcraft").ok_or_else(|| EngineError::Other("no native format".into()))?; + let saved = store.now(); + let mut out = vec![]; + for st in s.documents().iter().filter(|st| skip_reason(s, st).is_none()) { + let title = st.title(); + let file = match &st.recovery { + Some(c) => c.file.clone(), + None => { + let f = fresh_name(&area, &safe_stem(&title), &taken); + taken.push(f.clone()); + f + } + }; + // Written as a save writes it, links relative to the document's own file. + let plan = SavePlan { + mode: SaveMode::Copy, + path: st.path.clone(), + format: native, + options: Default::default(), + name: format!("{file}{DOC_EXT}"), + folder: None, + modified: None, + }; + let save = fileio::job_for(s, st, plan)?; + let meta = Meta { title: title.clone(), path: st.path.clone(), format: st.format.to_string(), saved }; + out.push(RecoveryJob { uid: st.uid, file, title, meta, save }); + } + Ok((out, skipped)) +} + +/// Remove copy `file` from this session's area. Best effort: a copy that can't be removed is +/// offered again after the next launch, which loses nothing. +fn remove_copy(s: &Session, file: &str) { + if let Some(own) = &s.recovery.own { + let _ = own.store.remove(&format!("{file}{DOC_EXT}")); + let _ = own.store.remove(&format!("{file}{META_EXT}")); + } +} + +/// Document `uid` was saved, reverted or closed: its recovery copy goes. +pub fn forget(s: &mut Session, uid: u64) { + let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return }; + remove_copy(s, ©.file); +} + +/// Quitting with nothing unsaved: the copies of every open document go, with this session's area. +pub fn forget_all(s: &mut Session) { + release(s); +} + +/// The copies of documents with no unsaved changes left (undone back to the saved state) go. +pub fn forget_clean(s: &mut Session) { + let uids: Vec = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect(); + for uid in uids { + forget(s, uid); + } +} + +fn save(s: &mut Session, _: &Value) -> Result { + let store = need_store(s, "file.recovery.save")?; + let (jobs, skipped) = jobs(s, &store)?; + let mut saved = vec![]; + for j in jobs { + saved.push(j.write(store.as_ref()).map_err(EngineError::Other)?); + j.finish(s); + } + forget_clean(s); + heartbeat(s); + Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() })) +} + +/// Who an area of the store belongs to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Owner { + /// This session. + Mine, + /// Another app that is running. + Running, + /// An app that is gone (crashed): its copies are offered. + Gone, +} + +/// Whose `area` of `store` is. A gone app's area is held by the caller (its lock in `hold` until +/// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so +/// another app leaves it alone meanwhile. +fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool) -> Owner { + if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { + return Owner::Mine; + } + match store.lock(area) { + Ok(Lock::Held(h)) => { + hold.push(h); + Owner::Gone + } + Ok(Lock::Busy) | Err(_) => Owner::Running, + Ok(Lock::Unsupported) => { + if !s.recovery.adopted.contains(area) && beating(store, area, stale_after(s)) { + return Owner::Running; + } + if take { + // Best effort: without it another app might offer the copies too, which loses + // nothing. + let _ = beat(store, area); + s.recovery.adopted.insert(area.to_string()); + } + Owner::Gone + } + } +} + +/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). +fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) { + let names = store.list().unwrap_or_default(); + let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect(); + if mine.iter().any(|n| n.ends_with(DOC_EXT)) { + return; + } + for n in mine { + let _ = store.remove(n); + } + drop(hold); + store.remove_area(area); +} + +/// The copies of documents open now in this session. +fn open_copies(s: &Session) -> Vec { + s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect() +} + +fn list(s: &mut Session, _: &Value) -> Result { + let store = need_store(s, "file.recovery.list")?; + let open = open_copies(s); + let copies = copies_in(&store.list().map_err(EngineError::Other)?); + let mut rows = vec![]; + for area in store.areas().map_err(EngineError::Other)? { + let mut hold = vec![]; + let who = owner(s, store.as_ref(), &area, &mut hold, false); + let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect(); + for f in &files { + let meta = read_meta(store.as_ref(), f); + let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; + rows.push(json!({ + "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, + "open": who == Owner::Mine && open.contains(f), "running": who == Owner::Running, + })); + } + // A gone app's empty area is tidied away. + if who == Owner::Gone && files.is_empty() { + tidy(store.as_ref(), &area, hold); + } + } + Ok(json!({ "copies": rows, "location": store.location() })) +} + +/// A gone app's area to act on: its name, the copies to act on, and its lock. +type Target = (String, Vec, Vec); + +/// The copies a command acts on, by gone app's area, each area held until its hold is dropped: +/// `file` (a copy a crash left behind), else every one. +fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> Result> { + let copies = copies_in(&store.list().map_err(EngineError::Other)?); + let file = str_param(p, "file"); + if let Some(f) = file + && !copies.iter().any(|(_, c)| c == f) + { + return Err(bad(cmd, format!("no recovery copy `{f}` (see file.recovery.list)"))); + } + let picked = |c: &str| file.is_none_or(|f| f == c); + let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); + let mut out = vec![]; + for area in areas { + let mut hold = vec![]; + match (owner(s, store, area, &mut hold, true), file) { + (Owner::Gone, _) => { + let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect(); + out.push((area.to_string(), files, hold)); + } + (Owner::Mine, Some(f)) => return Err(bad(cmd, format!("`{f}` is the copy of a document open now"))), + (Owner::Running, Some(f)) => return Err(bad(cmd, format!("`{f}` belongs to a VectorCraft that is running"))), + _ => {} + } + } + Ok(out) +} + +/// Open copy `file` as a new document: " [Recovered]", modified, with its original path +/// (for Save As) and format. The copy moves into this session's area (another crash keeps it). +fn restore_one(s: &mut Session, store: &Arc, file: &str) -> Result { + let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?; + let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default(); + let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default(); + let mut doc = vectorcraft_format::load(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?; + let area = claim(s, store)?; + let mut taken: Vec = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect(); + taken.extend(open_copies(s)); + let stem = file.rsplit('/').next().unwrap_or(file); + let mine = match format!("{area}/{stem}") { + f if taken.contains(&f) => fresh_name(&area, stem, &taken), + f => f, + }; + store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?; + store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?; + store.remove(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?; + store.remove(&format!("{file}{META_EXT}")).map_err(EngineError::Other)?; + if doc.title.is_empty() { + doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() }; + } + // Linked files are looked for from the document's own folder. + let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically"); + let index = s.add_document(doc, meta.path.clone()); + let st = s.doc_mut()?; + st.recovered = true; + st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); + st.mark_unsaved(); + // The copy stays until the document is saved or closed. + st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); + let title = st.title(); + Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path }), links.to_json())) +} + +fn restore(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.recovery.restore"; + let store = need_store(s, C)?; + let one = str_param(p, "file").is_some(); + let (mut restored, mut failed) = (vec![], vec![]); + for (area, files, hold) in targets(s, store.as_ref(), C, p)? { + for f in files { + match restore_one(s, &store, &f) { + Ok(r) => restored.push(r), + Err(e) if one => return Err(e), + // A damaged copy doesn't hold the others back. + Err(e) => failed.push(json!({ "file": f, "error": e.to_string() })), + } + } + tidy(store.as_ref(), &area, hold); + } + Ok(json!({ "restored": restored, "failed": failed })) +} + +fn discard(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.recovery.discard"; + let store = need_store(s, C)?; + let mut discarded = vec![]; + for (area, files, hold) in targets(s, store.as_ref(), C, p)? { + for f in files { + store.remove(&format!("{f}{DOC_EXT}")).map_err(EngineError::Other)?; + store.remove(&format!("{f}{META_EXT}")).map_err(EngineError::Other)?; + discarded.push(f); + } + tidy(store.as_ref(), &area, hold); + } + Ok(json!({ "discarded": discarded })) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.recovery.save", + "Save Recovery Data", + [], + None, + "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", + has_store, + save + ), + cmd!( + query "file.recovery.list", + "Recovery Data", + [], + None, + "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash", + has_store, + list + ), + cmd!( + "file.recovery.restore", + "Restore Recovered Documents", + [], + None, + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + has_store, + restore + ), + cmd!( + "file.recovery.discard", + "Discard Recovered Documents", + [], + None, + "{file?} delete a copy left behind by a crash (default: every one; copies of open documents and of running VectorCraft apps stay) → {discarded: [file…]}", + has_store, + discard + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c14cd2483..7bc0c5b5d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -124,6 +124,11 @@ pub struct DocState { /// The view saved into native files (`Document::last_view`); the UI keeps it current before a /// save and restores it when the document opens. pub view: Option, + /// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it + /// (suggesting `path`, the file it was copied from) instead of overwriting that file. + pub recovered: bool, + /// The document's Data Recovery copy, once one was written ([`cmd::recovery`]). + pub recovery: Option, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -153,6 +158,8 @@ impl DocState { save_options: Default::default(), converted: false, view, + recovered: false, + recovery: None, } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -186,6 +193,10 @@ impl DocState { self.revision += 1; } } + /// Count the document as modified, as if never saved (a document restored by Data Recovery). + pub fn mark_unsaved(&mut self) { + self.saved_doc = Arc::new(Document::new(1.0, 1.0)); + } pub fn title(&self) -> String { let name = self .path @@ -193,7 +204,13 @@ impl DocState { .and_then(|p| std::path::Path::new(p).file_name()) .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| self.doc.title.clone()); - if self.converted { format!("{} [Converted]", cmd::fileio::file_stem(&name)) } else { name } + if self.converted { + format!("{} [Converted]", cmd::fileio::file_stem(&name)) + } else if self.recovered { + format!("{} [Recovered]", cmd::fileio::file_stem(&name)) + } else { + name + } } /// Where new art is inserted: the isolation container, else the active layer. /// The current layer, if the remembered id still names a layer (ids are reused after undo). @@ -632,6 +649,8 @@ pub struct Session { /// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an /// error brings back; `None` otherwise. pub(crate) batch_stash: Option>, + /// Where Data Recovery keeps its copies ([`cmd::recovery`]). + pub recovery: cmd::recovery::Recovery, } impl Default for Session { @@ -671,6 +690,7 @@ impl Session { journal_note: Default::default(), note_depth: 1, batch_stash: None, + recovery: Default::default(), } } @@ -791,6 +811,9 @@ impl Session { return false; } self.reset_tool_for_doc_switch(); + // Closed (saved or discarded): nothing left to recover. + let uid = self.docs[index].uid; + cmd::recovery::forget(self, uid); let old = self.docs.remove(index); if let Some(stash) = &mut self.batch_stash { stash.push(old); @@ -1176,6 +1199,8 @@ mod tests_rastersettings; #[cfg(test)] mod tests_recolor; #[cfg(test)] +mod tests_recovery; +#[cfg(test)] mod tests_registration; #[cfg(test)] mod tests_save; diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs new file mode 100644 index 000000000..b503f3879 --- /dev/null +++ b/crates/engine/src/tests_recovery.rs @@ -0,0 +1,361 @@ +//! Data Recovery: `file.recovery.save/list/restore/discard`, copies removed on save, revert and +//! close, complex documents skipped, each app's own area (another running app's copies are never +//! offered: locks, heartbeats), and the folder store with real file locks. + +use std::sync::Arc; + +use serde_json::json; + +use super::*; +use crate::cmd::recovery::{FolderStore, MemoryStore, RecoveryStore}; + +/// A session keeping its copies in `store`, with one modified document. +fn session_in(store: &Arc) -> Session { + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + s +} + +/// A session keeping its copies in memory (the store is returned to look inside), with one +/// modified document. +fn session() -> (Session, Arc) { + let store = Arc::new(MemoryStore::default()); + (session_in(&store), store) +} + +/// The copies in the store, without their areas. +fn copies(store: &dyn RecoveryStore) -> Vec { + let mut v: Vec = + store.list().unwrap().iter().filter_map(|n| n.strip_suffix(".vectorcraft")).map(|n| n.split_once('/').unwrap().1.to_string()).collect(); + v.sort(); + v +} + +/// The titles of the copies `file.recovery.list` offers after a crash (neither open here nor +/// kept by a running app). +fn offered(s: &mut Session) -> Vec { + let l = s.execute("file.recovery.list", &json!({})).unwrap(); + l["copies"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["open"] == false && c["running"] == false) + .map(|c| c["title"].as_str().unwrap().to_string()) + .collect() +} + +/// A crash: a new session over the same store (the copies stay behind; dropping the old session +/// releases its lock, as the system does for a process that ends). +fn relaunch(store: &Arc) -> Session { + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s +} + +fn restored(r: serde_json::Value) -> usize { + r["restored"].as_array().unwrap().len() +} + +#[test] +fn recovery_save_writes_modified_documents_once() { + let (mut s, store) = session(); + // A clean second document gets no copy. + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}"); + let area = s.recovery.own_area().unwrap().to_string(); + assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1")); + assert_eq!(store.list().unwrap(), [format!("{area}/Untitled-1-1.json"), format!("{area}/Untitled-1-1.vectorcraft")]); + let doc = store.read(&format!("{area}/Untitled-1-1.vectorcraft")).unwrap(); + assert_eq!(vectorcraft_format::load(&doc).unwrap().node_count(), s.documents()[0].doc.node_count()); + // Unchanged since: nothing written again. + let again = s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(again["saved"].as_array().unwrap().is_empty()); + assert_eq!(again["skipped"][0]["reason"], "unchanged since its last recovery copy"); + // Changed: the same copy is rewritten. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1")); + assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]); + // The list shows it as the copy of an open document, not one to offer. + let l = s.execute("file.recovery.list", &json!({})).unwrap(); + assert_eq!((l["copies"][0]["title"].as_str(), l["copies"][0]["open"].as_bool()), (Some("Untitled-1"), Some(true))); + assert_eq!(l["location"], "memory"); + assert!(offered(&mut s).is_empty()); +} + +#[test] +fn a_crash_leaves_copies_that_restore_modified_and_titled() { + let (mut s, store) = session(); + s.doc_mut().unwrap().path = Some("/art/Poster.svg".into()); + s.doc_mut().unwrap().format = "svg"; + s.execute("file.recovery.save", &json!({})).unwrap(); + let nodes = s.doc().unwrap().doc.node_count(); + let crashed = s.recovery.own_area().unwrap().to_string(); + drop(s); + + let mut s = relaunch(&store); + let l = s.execute("file.recovery.list", &json!({})).unwrap(); + let copy = &l["copies"][0]; + assert_eq!(copy["file"], format!("{crashed}/Poster-1")); + assert_eq!((copy["path"].as_str(), copy["open"].as_bool(), copy["running"].as_bool()), (Some("/art/Poster.svg"), Some(false), Some(false))); + let r = s.execute("file.recovery.restore", &json!({})).unwrap(); + assert_eq!(r["restored"][0]["title"], "Poster [Recovered]"); + let st = s.doc().unwrap(); + assert_eq!(st.title(), "Poster [Recovered]"); + assert!(st.is_dirty() && st.recovered, "restored documents are unsaved"); + assert_eq!((st.path.as_deref(), st.format, st.doc.node_count()), (Some("/art/Poster.svg"), "svg", nodes)); + // The copy moved into this session's area; the crashed one's area is gone. + let mine = s.recovery.own_area().unwrap().to_string(); + assert_eq!(st.recovery.as_ref().unwrap().file, format!("{mine}/Poster-1")); + assert_eq!(store.areas().unwrap(), std::slice::from_ref(&mine)); + // Save asks for a path (suggesting the original file) rather than overwriting it. + let saved = s.execute("document.save", &json!({})).unwrap(); + assert!(saved.get("dataBase64").is_some(), "no path: {saved}"); + assert_eq!(saved["name"], "Poster.svg"); + // Restored once: nothing is left to restore. + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 0); + assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy"); +} + +#[test] +fn another_running_apps_copies_are_never_offered() { + let store = Arc::new(MemoryStore::default()); + let mut a = session_in(&store); + a.execute("file.recovery.save", &json!({})).unwrap(); + let a_file = format!("{}/Untitled-1-1", a.recovery.own_area().unwrap()); + // B runs at the same time: A's copy is listed as running, never offered or taken. + let mut b = session_in(&store); + b.execute("file.recovery.save", &json!({})).unwrap(); + let l = b.execute("file.recovery.list", &json!({})).unwrap(); + let rows = l["copies"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().any(|c| c["file"] == a_file.as_str() && c["running"] == true)); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + let e = b.execute("file.recovery.discard", &json!({"file": a_file})).unwrap_err().to_string(); + assert!(e.contains("running"), "{e}"); + assert_eq!(copies(store.as_ref()).len(), 2, "nothing deleted"); + // A quits normally: its area goes, and B still has nothing to offer. + a.execute("file.close", &json!({})).unwrap(); + cmd::recovery::forget_all(&mut a); + assert!(offered(&mut b).is_empty()); + assert_eq!(copies(store.as_ref()), ["Untitled-1-1"], "B's own copy"); +} + +#[test] +fn a_crashed_apps_copies_are_offered_once_its_lock_is_released() { + let store = Arc::new(MemoryStore::default()); + let mut a = session_in(&store); + a.execute("file.recovery.save", &json!({})).unwrap(); + let mut b = relaunch(&store); + assert!(offered(&mut b).is_empty(), "A still runs"); + drop(a); + assert_eq!(offered(&mut b), ["Untitled-1"]); + // Restored by B, the copy is B's: C, launched at the same time, can't take it. + let mut c = relaunch(&store); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert!(offered(&mut c).is_empty(), "B owns the copy now"); + assert_eq!(restored(c.execute("file.recovery.restore", &json!({})).unwrap()), 0); +} + +#[test] +fn heartbeats_hold_areas_where_the_store_has_no_locks() { + let store = Arc::new(MemoryStore::without_locks()); + store.set_now(10_000); + let mut a = session_in(&store); + a.execute("file.recovery.save", &json!({})).unwrap(); + let mut b = relaunch(&store); + b.prefs.autosave_interval = 2; + // Three intervals (6 minutes) without a heartbeat: until then A counts as running. + store.set_now(10_000 + 359); + assert!(offered(&mut b).is_empty()); + // A beats (as the app does every minute): fresh again. + cmd::recovery::heartbeat(&a); + store.set_now(10_000 + 359 + 300); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + // A stops beating (it crashed): its copies are offered once three intervals have passed. + drop(a); + store.set_now(10_000 + 359 + 361); + assert_eq!(offered(&mut b), ["Untitled-1"]); + let mut c = relaunch(&store); + // B restores the copies one by one (as the dialog does): its own heartbeat on A's area doesn't + // stop it, and C sees the area taken. + let file = b.execute("file.recovery.list", &json!({})).unwrap()["copies"][0]["file"].as_str().unwrap().to_string(); + b.execute("file.recovery.restore", &json!({"file": file})).unwrap(); + assert!(offered(&mut c).is_empty()); + assert_eq!(b.doc().unwrap().title(), "Untitled-1 [Recovered]"); + // Never under three minutes, whatever the interval. + let store = Arc::new(MemoryStore::without_locks()); + let mut a = session_in(&store); + a.execute("file.recovery.save", &json!({})).unwrap(); + drop(a); + let mut b = relaunch(&store); + b.prefs.autosave_interval = 1; + store.set_now(1_000_000 + 179); + assert!(offered(&mut b).is_empty()); + store.set_now(1_000_000 + 181); + assert_eq!(offered(&mut b), ["Untitled-1"]); +} + +#[test] +fn folder_areas_are_locked_by_running_apps() { + let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().to_string(); + let app = |dirty: bool| { + let mut s = Session::new(); + s.prefs.recovery_folder = folder.clone(); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + if dirty { + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + } + s + }; + let mut a = app(true); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + assert!(dir.join(&area).join(".lock").is_file() && dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + // The lock file is held: another app (another handle) can't take it. + let store = FolderStore::new(&dir); + assert!(matches!(store.lock(&area), Ok(cmd::recovery::Lock::Busy))); + let mut b = app(false); + assert!(offered(&mut b).is_empty()); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + // A crashes (its handle closes): B offers its copy, and restoring it tidies A's folder away. + drop(a); + assert_eq!(offered(&mut b), ["Untitled-1"]); + b.execute("file.recovery.restore", &json!({})).unwrap(); + assert!(!dir.join(&area).exists(), "the crashed app's folder is removed"); + let mine = b.recovery.own_area().unwrap().to_string(); + assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file()); + // An empty folder a crash left (no copies) is tidied by the next list. + std::fs::create_dir_all(dir.join("123-1")).unwrap(); + std::fs::write(dir.join("123-1").join(".lock"), b"").unwrap(); + b.execute("file.recovery.list", &json!({})).unwrap(); + assert!(!dir.join("123-1").exists()); + // B quits with nothing unsaved: its folder goes too. + b.execute("file.close", &json!({})).unwrap(); + b.execute("file.close", &json!({})).unwrap(); + cmd::recovery::forget_all(&mut b); + assert!(!dir.join(&mine).exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn saving_reverting_or_closing_removes_the_copy() { + let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.vectorcraft").to_string_lossy().to_string(); + // Save to a file. + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(copies(store.as_ref()).len(), 1); + s.execute("document.save", &json!({"path": path})).unwrap(); + assert!(copies(store.as_ref()).is_empty(), "saved: the copy goes"); + assert!(s.doc().unwrap().recovery.is_none()); + // Revert. + s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(copies(store.as_ref()).len(), 1); + s.execute("file.revert", &json!({})).unwrap(); + assert!(copies(store.as_ref()).is_empty(), "reverted: the copy goes"); + // Close (discarding the changes). + s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert!(copies(store.as_ref()).is_empty(), "closed: the copy goes"); + // A restored document's copy goes once it is saved. + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + drop(s); + let mut s2 = relaunch(&store); + assert_eq!(restored(s2.execute("file.recovery.restore", &json!({})).unwrap()), 1); + s2.execute("document.save", &json!({"path": path})).unwrap(); + assert!(copies(store.as_ref()).is_empty()); + let st = s2.doc().unwrap(); + assert!(!st.recovered && !st.is_dirty()); + assert_eq!(st.title(), "doc.vectorcraft"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn undoing_back_to_the_saved_state_drops_the_copy() { + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(r["saved"].as_array().unwrap().is_empty() && r["skipped"].as_array().unwrap().is_empty(), "{r}"); + assert!(copies(store.as_ref()).is_empty()); +} + +#[test] +fn complex_documents_are_skipped_when_the_preference_says() { + let store = Arc::new(MemoryStore::default()); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0); + let layer = d.layers[0].id; + for i in 0..cmd::recovery::COMPLEX_OBJECTS { + let id = d.alloc_id(); + let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0); + let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()); + d.insert(Some(layer), i, n).unwrap(); + } + s.add_document(d, None); + // One more object makes it complex (and modified). + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s.prefs.recovery_off_for_complex = true; + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(r["saved"].as_array().unwrap().is_empty()); + assert!(r["skipped"][0]["reason"].as_str().unwrap().starts_with("complex document"), "{r}"); + assert!(store.list().unwrap().is_empty(), "no copy, no area"); + s.prefs.recovery_off_for_complex = false; + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().len(), 1); +} + +#[test] +fn discard_deletes_copies_left_behind_only() { + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + let crashed = s.recovery.own_area().unwrap().to_string(); + drop(s); + let mut s = relaunch(&store); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(copies(store.as_ref()).len(), 2); + assert!(s.execute("file.recovery.discard", &json!({"file": "nope"})).is_err()); + // The open document's copy stays. + let r = s.execute("file.recovery.discard", &json!({})).unwrap(); + assert_eq!(r["discarded"], json!([format!("{crashed}/Untitled-1-1")])); + let mine = s.recovery.own_area().unwrap(); + assert_eq!(store.list().unwrap(), [format!("{mine}/Untitled-1-1.json"), format!("{mine}/Untitled-1-1.vectorcraft")]); +} + +#[test] +fn without_a_store_the_commands_say_where_to_set_one() { + let mut s = Session::new(); + let e = s.execute("file.recovery.list", &json!({})).unwrap_err().to_string(); + assert!(e.contains("recoveryFolder"), "{e}"); + // The preference names a folder: copies go there. + let dir = std::env::temp_dir().join(format!("vc-recovery-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + s.prefs.recovery_folder = dir.to_string_lossy().to_string(); + assert!(s.execute("file.recovery.list", &json!({})).unwrap()["copies"].as_array().unwrap().is_empty(), "no folder yet: no copies"); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + let area = dir.join(s.recovery.own_area().unwrap()); + assert!(area.join("Untitled-1-1.vectorcraft").is_file() && area.join("Untitled-1-1.json").is_file()); + s.execute("file.close", &json!({})).unwrap(); + assert!(!area.join("Untitled-1-1.vectorcraft").exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index ef1093357..e28c28761 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -328,3 +328,38 @@ fn every_command_undoes_exactly() { } assert!(failures.is_empty(), "{}", failures.join("\n")); } + +/// Data Recovery reads whatever its store holds: damaged copies and details fail cleanly, and junk +/// params never panic. +#[test] +fn recovery_commands_with_a_damaged_store_and_junk_params() { + use std::sync::Arc; + use vectorcraft_engine::cmd::recovery::{MemoryStore, RecoveryStore}; + let store = Arc::new(MemoryStore::default()); + for (name, bytes) in [ + ("1-1/bad-1.vectorcraft", &b"{not json"[..]), + ("1-1/bad-1.json", b"[1, 2"), + ("1-1/gz-1.vectorcraft", &[0x1f, 0x8b, 0, 1, 2][..]), + ("1-1/meta-only.json", br#"{"title": 5, "path": [], "saved": "x"}"#), + ("2-1/heartbeat", b"not a number"), + ("2-1/x.vectorcraft", b"{}"), + ("../escape.vectorcraft", b"{}"), + ("no-area.vectorcraft", b"{}"), + ("/.vectorcraft", b"{}"), + ] { + store.write(name, bytes).unwrap(); + } + let path = safe_path(); + for fx in Fixture::ALL { + for id in ["file.recovery.save", "file.recovery.list", "file.recovery.restore", "file.recovery.discard"] { + let spec = command_specs().iter().find(|c| c.id == id).unwrap(); + for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + let mut s = fx.session(); + s.recovery.set_store(store.clone()); + let r = catch_quiet(|| s.execute(id, &p)); + assert!(r.is_ok(), "PANIC {id} {p} [{fx:?}]"); + check_session(&s).unwrap_or_else(|e| panic!("{id} {p} [{fx:?}]: {e}")); + } + } + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index cdcf82933..512b7329d 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -42,6 +42,7 @@ pub mod placement_options; mod png_options; pub mod raster_effects; pub mod recolor; +mod recovery; pub mod saturate; mod save_changes; pub(crate) mod save_options; @@ -223,6 +224,7 @@ registry! { SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, + Recovery: [crate::recovery::KIND] => recovery::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/recovery.rs b/crates/ui-egui/src/dialogs/recovery.rs new file mode 100644 index 000000000..59a8fa10a --- /dev/null +++ b/crates/ui-egui/src/dialogs/recovery.rs @@ -0,0 +1,37 @@ +//! Recover Documents (shown at launch when the last session left recovery copies behind): +//! Restore / Discard / Cancel (ask again next launch). The flow lives in [`crate::recovery`]; this +//! is its face in the shared dialog frame. + +use serde_json::Value; + +use super::DialogSpec; +use crate::theme::Tokens; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Recover Documents".into(), + body: |_, ui, d| { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("VectorCraft didn't quit normally last time. These documents had unsaved changes:").color(t.text_dim)); + ui.add_space(8.0); + let copies = d.fields.get("copies").and_then(Value::as_array); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for c in copies.into_iter().flatten() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(c["title"].as_str().unwrap_or_default()).color(t.text)); + if let Some(at) = c["saved"].as_i64().and_then(|s| u64::try_from(s).ok()) { + ui.label(egui::RichText::new(crate::panels::links::date_label(at.saturating_mul(1000))).color(t.text_dim)); + } + }); + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("Restored documents open unsaved: save them to keep them.").color(t.text_dim)); + false + }, + confirm: crate::recovery::confirm, + ok: Some("Restore"), + discard: Some("Discard"), + min_width: 380.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index fd1808c8c..04f1d8e07 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -312,9 +312,7 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let then = move |app: &mut VectorcraftApp, r: Result| { let r = r?; // The document may have closed meanwhile: the file is written all the same. - if let Some(st) = app.session.document_mut(uid) { - done.finish(st); - } + done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); if retargets { note_recent(app, &path); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 02e81234a..ad05e4806 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod palette; pub mod panels; pub mod place; pub mod prefs_dialog; +pub mod recovery; pub mod render_worker; pub mod shortcut_editor; pub mod shortcuts; @@ -54,6 +55,8 @@ mod tests_place; #[cfg(test)] mod tests_recolor; #[cfg(test)] +mod tests_recovery; +#[cfg(test)] mod tests_save; #[cfg(test)] mod tests_slices; @@ -148,6 +151,9 @@ pub struct Services { pub open_file: Option, /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). pub pick_folder: Option Option>>, + /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; + /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). + pub recovery_store: Option>, } /// Cached canvas raster. @@ -240,13 +246,18 @@ pub struct VectorcraftApp { pub(crate) paste_chord: shortcuts::PasteChord, /// Saves and exports running in the background (Preferences → File Handling). pub background: background::Background, + /// Data Recovery's timer and startup question ([`recovery`]). + pub recovery: recovery::Timer, } /// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; impl VectorcraftApp { - pub fn new(session: Session, services: Services) -> Self { + pub fn new(mut session: Session, services: Services) -> Self { + if let Some(store) = &services.recovery_store { + session.recovery.set_store(store.clone()); + } let views = session.documents().iter().map(View::of).collect(); Self { session, @@ -288,6 +299,7 @@ impl VectorcraftApp { system_paste_at: f64::NEG_INFINITY, paste_chord: Default::default(), background: Default::default(), + recovery: Default::default(), } } @@ -566,8 +578,15 @@ impl VectorcraftApp { if let Err(e) = unsaved::close_all(self, "quit") { self.status(e); } + } else { + // Quitting with nothing unsaved: no copies to leave behind. + vectorcraft_engine::cmd::recovery::forget_all(&mut self.session); } } + if let Some(wait) = recovery::frame(self, now) { + // The timer and heartbeat run in an idle window too. + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } shortcut_editor::sync(&self.ui); prefs_dialog::apply_runtime(self, ctx); self.drain_control(ctx); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 617aa9ade..0bd233475 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -335,7 +335,7 @@ fn size_label(bytes: u64) -> String { } /// Milliseconds since the Unix epoch as a UTC date and time. -fn date_label(ms: u64) -> String { +pub(crate) fn date_label(ms: u64) -> String { let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(i64::try_from(ms / 1000).unwrap_or(i64::MAX)); format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02} UTC") } diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs new file mode 100644 index 000000000..db9d94f93 --- /dev/null +++ b/crates/ui-egui/src/recovery.rs @@ -0,0 +1,135 @@ +//! Data Recovery in the app: the timer that keeps recovery copies of modified documents every +//! Preferences → File Handling → Data Recovery interval (written in the background like Background +//! Save, the status bar showing it), and the dialog the first frame shows when the last session +//! left copies behind (a crash). The copies themselves are the engine's +//! ([`vectorcraft_engine::cmd::recovery`]). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::recovery; + +use crate::VectorcraftApp; +use crate::background::{self, Writer}; +use crate::state::Dialog; + +/// Dialog kind of the startup question. Fields: `copies` (`[{file, title, saved}]`, from +/// `file.recovery.list`) and `discard` (Discard instead of Restore). +pub const KIND: &str = "recovery"; + +/// The timer's state. +#[derive(Default)] +pub struct Timer { + /// The first frame looked for copies left behind. + checked: bool, + /// When copies were last written (app time, s); the interval runs from there. + last: Option, + /// When the heartbeat was last refreshed (app time, s). + beat: f64, +} + +/// Each frame: the startup question once, the timer and the heartbeat (`now`: app time in +/// seconds) → in how many seconds they want the next frame (none while Data Recovery is off). +pub fn frame(app: &mut VectorcraftApp, now: f64) -> Option { + if !app.recovery.checked { + app.recovery.checked = true; + offer(app); + } + tick(app, now); + if !(0.0..recovery::HEARTBEAT_EVERY).contains(&(now - app.recovery.beat)) { + app.recovery.beat = now; + recovery::heartbeat(&app.session); + } + let interval = f64::from(app.session.prefs.autosave_interval.max(1)) * 60.0; + let next = app.recovery.last.map(|t| t + interval - now)?; + Some(next.min(app.recovery.beat + recovery::HEARTBEAT_EVERY - now).max(1.0)) +} + +/// Ask about the copies a crash left behind, if any (the Recover Documents dialog). → whether it +/// asked. +pub fn offer(app: &mut VectorcraftApp) -> bool { + if recovery::store(&app.session).is_none() || app.ui.dialog.is_some() { + return false; + } + let Ok(r) = app.session.execute("file.recovery.list", &json!({})) else { return false }; + // Neither this app's own nor kept by another VectorCraft that is running. + let left: Vec = r["copies"].as_array().into_iter().flatten().filter(|c| c["open"] == false && c["running"] == false).cloned().collect(); + if left.is_empty() { + return false; + } + app.ui.dialog = Some(Dialog::new(KIND, json!({ "copies": left }))); + true +} + +/// Start a recovery copy of every document that needs one when the interval has passed since the +/// last (the first call only starts the clock) → how many were started. +pub fn tick(app: &mut VectorcraftApp, now: f64) -> usize { + let prefs = &app.session.prefs; + let Some(store) = recovery::store(&app.session).filter(|_| prefs.autosave_recovery) else { + app.recovery.last = None; + return 0; + }; + let interval = f64::from(prefs.autosave_interval.max(1)) * 60.0; + match app.recovery.last { + Some(t) if now >= t && now - t < interval => return 0, + Some(t) if now >= t => app.recovery.last = Some(now), + // First call, or a clock that went back: the interval starts now. + _ => { + app.recovery.last = Some(now); + return 0; + } + } + recovery::forget_clean(&mut app.session); + let jobs = match recovery::jobs(&mut app.session, &store) { + // A document whose save or last copy is still being written waits for the next round. + Ok((jobs, _)) => jobs.into_iter().filter(|j| !app.background.busy_with(j.uid)).collect::>(), + Err(e) => { + app.status(format!("Couldn't save recovery data: {e}")); + return 0; + } + }; + let mut started = 0; + for job in jobs { + let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone()); + let (done, store) = (job.clone(), store.clone()); + let work = move |_: Writer| job.write(store.as_ref()); + let then = move |app: &mut VectorcraftApp, r: Result| { + let r = r?; + done.finish(&mut app.session); + app.status(format!("Saved recovery data for {title}")); + Ok(r) + }; + // Its own store, not the file writer: this only decides whether it runs on the worker. + if let Err(e) = background::run(app, true, label, Some(uid), work, then) { + app.status(format!("Couldn't save recovery data: {e}")); + } + started += 1; + } + started +} + +/// The startup dialog's OK (Restore) or Discard: every copy it lists. +pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + let id = if d.bool("discard") { "file.recovery.discard" } else { "file.recovery.restore" }; + let files: Vec = + d.fields.get("copies").and_then(Value::as_array).into_iter().flatten().filter_map(|c| c["file"].as_str()).map(str::to_string).collect(); + let (mut done, mut errors) = (vec![], vec![]); + for file in files { + match app.run(id, json!({ "file": file })) { + Ok(r) => { + if let Some(restored) = r["restored"].as_array().and_then(|v| v.first()) { + crate::dialogs::missing_links::after_open(app, restored); + } + done.push(r); + } + Err(e) => errors.push(e), + } + } + if !errors.is_empty() { + return Err(errors.join("; ")); + } + app.status(match id { + "file.recovery.discard" => format!("Discarded {} recovered document(s)", done.len()), + _ => format!("Restored {} recovered document(s): save them to keep them", done.len()), + }); + Ok(json!(done)) +} diff --git a/crates/ui-egui/src/tests_recovery.rs b/crates/ui-egui/src/tests_recovery.rs new file mode 100644 index 000000000..c865442b9 --- /dev/null +++ b/crates/ui-egui/src/tests_recovery.rs @@ -0,0 +1,178 @@ +//! Data Recovery in the app: the timer (driven by a fake clock), copies written in the background, +//! the Recover Documents dialog at launch, and quitting leaving no copies behind. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::recovery::{MemoryStore, RecoveryStore}; + +use crate::{Services, VectorcraftApp, background, dialogs, recovery, theme}; + +/// An app keeping its copies in `store`, with a modified document. +fn app_with(store: &Arc, services: Services) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..services }); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + app +} + +fn copies(store: &MemoryStore) -> usize { + store.list().unwrap().iter().filter(|n| n.ends_with(".vectorcraft")).count() +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +#[test] +fn the_timer_writes_modified_documents_every_interval() { + let store = Arc::new(MemoryStore::default()); + let mut app = app_with(&store, Services::default()); + app.session.prefs.autosave_interval = 2; + // The first tick starts the clock; nothing before the interval has passed. + assert_eq!(recovery::tick(&mut app, 10.0), 0); + assert_eq!(recovery::tick(&mut app, 129.0), 0); + assert_eq!(copies(&store), 0); + assert_eq!(recovery::tick(&mut app, 130.0), 1); + assert_eq!(copies(&store), 1); + assert_eq!(app.ui.status, "Saved recovery data for Untitled-1"); + // Unchanged: the next round writes nothing. + assert_eq!(recovery::tick(&mut app, 250.0), 0); + app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + assert_eq!(recovery::tick(&mut app, 300.0), 0, "the interval runs from the last round"); + assert_eq!(recovery::tick(&mut app, 370.0), 1); + assert_eq!(copies(&store), 1, "the same copy, rewritten"); + // Turned off: no copies, and the clock starts again when turned back on. + app.session.prefs.autosave_recovery = false; + app.run("shape.ellipse", json!({"x": 150, "y": 10, "width": 20, "height": 20})).unwrap(); + assert_eq!(recovery::tick(&mut app, 1000.0), 0); + app.session.prefs.autosave_recovery = true; + assert_eq!(recovery::tick(&mut app, 1001.0), 0); + assert_eq!(recovery::tick(&mut app, 1121.0), 1); + // A clock that goes back restarts the interval instead of writing. + app.run("shape.ellipse", json!({"x": 10, "y": 50, "width": 20, "height": 20})).unwrap(); + assert_eq!(recovery::tick(&mut app, 5.0), 0); + assert_eq!(recovery::tick(&mut app, 125.0), 1); +} + +#[test] +fn copies_are_written_in_the_background_and_go_on_save() { + let store = Arc::new(MemoryStore::default()); + let services = Services { + write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), + write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))), + ..Default::default() + }; + let mut app = app_with(&store, services); + recovery::tick(&mut app, 0.0); + assert_eq!(recovery::tick(&mut app, 120.0), 1); + assert_eq!(app.background.jobs.len(), 1, "on the worker"); + assert!(app.ui.status.starts_with("Saving recovery data for Untitled-1"), "{}", app.ui.status); + background::wait_all(&mut app); + assert_eq!(copies(&store), 1); + assert!(app.session.doc().unwrap().recovery.is_some()); + // Saving (in the background too) removes it. + app.run("file.save", json!({"path": "/docs/a.vectorcraft"})).unwrap(); + background::wait_all(&mut app); + assert_eq!(copies(&store), 0); +} + +#[test] +fn launching_after_a_crash_offers_the_copies() { + let store = Arc::new(MemoryStore::default()); + let mut crashed = app_with(&store, Services::default()); + crashed.run("file.recovery.save", json!({})).unwrap(); + drop(crashed); + + // Restore. + let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() }); + recovery::frame(&mut app, 0.0); + let d = app.ui.dialog.clone().expect("the Recover Documents dialog"); + assert_eq!((d.kind.as_str(), d.fields["copies"][0]["title"].as_str()), (recovery::KIND, Some("Untitled-1"))); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "drawing keeps it open"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + assert_eq!(st.title(), "Untitled-1 [Recovered]"); + assert!(st.is_dirty()); + assert_eq!(app.views.len(), 1); + // Asked once per launch. + recovery::frame(&mut app, 1.0); + assert!(app.ui.dialog.is_none()); + // Closing it discards its copy; nothing is offered next time. + app.run("file.close", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(copies(&store), 0); + let mut next = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() }); + recovery::frame(&mut next, 0.0); + assert!(next.ui.dialog.is_none()); +} + +#[test] +fn discard_at_launch_deletes_the_copies_and_cancel_keeps_them() { + let store = Arc::new(MemoryStore::default()); + let mut crashed = app_with(&store, Services::default()); + crashed.run("file.recovery.save", json!({})).unwrap(); + drop(crashed); + // Cancel: asked again next launch. + let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() }); + assert!(recovery::offer(&mut app)); + dialogs::cancel(&mut app); + assert_eq!(copies(&store), 1); + // Discard (as agents do: set discard, confirm). + let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() }); + assert!(recovery::offer(&mut app)); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(copies(&store), 0); + assert!(app.session.documents().is_empty()); +} + +#[test] +fn quitting_with_nothing_unsaved_leaves_no_copies() { + let store = Arc::new(MemoryStore::default()); + let mut app = app_with(&store, Services::default()); + app.run("file.recovery.save", json!({})).unwrap(); + assert_eq!(copies(&store), 1); + // Undone back to the saved state, then quit: the copy goes. + app.run("edit.undo", json!({})).unwrap(); + app.run("app.quit", json!({})).unwrap(); + assert_eq!(app.ui.status, "quit"); + assert_eq!(copies(&store), 0); +} + +#[test] +fn a_second_app_never_asks_about_a_running_apps_copies() { + let store = Arc::new(MemoryStore::default()); + let mut a = app_with(&store, Services::default()); + a.run("file.recovery.save", json!({})).unwrap(); + // B launches while A runs: no Recover Documents dialog, A's copy untouched. + let mut b = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() }); + recovery::frame(&mut b, 0.0); + assert!(b.ui.dialog.is_none()); + assert_eq!(copies(&store), 1); + // A crashes: the next launch asks. + drop(a); + let mut c = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() }); + recovery::frame(&mut c, 0.0); + assert_eq!(c.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(recovery::KIND)); +} + +#[test] +fn the_timer_asks_for_frames_while_idle() { + let store = Arc::new(MemoryStore::without_locks()); + let mut app = app_with(&store, Services::default()); + app.session.prefs.autosave_interval = 2; + // The interval and the heartbeat each want a frame on time, even with no input. + let wait = recovery::frame(&mut app, 0.0).unwrap(); + assert!((1.0..=60.0).contains(&wait), "{wait}"); + app.session.prefs.autosave_recovery = false; + assert!(recovery::frame(&mut app, 1.0).is_none()); +} diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 24ddb1604..fc8e6d171 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -32,6 +32,8 @@ pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result return ask(app, i, then); } if then == "quit" { + // Nothing unsaved is left: no recovery copies either. + vectorcraft_engine::cmd::recovery::forget_all(&mut app.session); // The host closes the window. app.ui.status = "quit".into(); return Ok(Value::Null); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index adbc832aa..99b91dae7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -339,3 +339,10 @@ EPS Options: Export As… with the EPS format (`file.exportAs {format: "eps"}`, `cmykPostScript`, `compatibleGradients`, `level` (`2`, `3`), plus `path` and the artboard choice (`useArtboards`, `range`). `ui.dialog.confirm` checks them (a bad value or an unknown preset keeps the dialog open), remembers them for the next EPS Options and writes the file(s), asking for a path when there is none. + +Data Recovery: at launch, copies left behind by a crash open the `recovery` dialog (never copies another running +instance keeps, so instances started side by side for screenshots don't get it). Fields: `copies` (`[{file, title, +saved}]`, from `file.recovery.list`) and `discard`. `ui.dialog.confirm` restores them all (`file.recovery.restore` +each, opening " [Recovered]" documents), `discard: true` then confirm deletes them, `ui.dialog.cancel` keeps them +for the next launch. While copies are written in the background `ui.inspect` lists them under `background` +("Saving recovery data for "). diff --git a/docs/mcp.md b/docs/mcp.md index d78f2db67..906c8d386 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1265,3 +1265,29 @@ as they do for art larger than the imageable area and for overprints in composit {"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"output":{"mode":"separations"}}}}} {"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf","settings":{"copies":2}}}} ``` + +## Data Recovery + +Modified documents get recovery copies (`file.recovery.save`; the app runs it every `autosaveInterval` minutes while +`autosaveRecovery` is on, skipping documents of more than 20,000 objects while `recoveryOffForComplex` is on). A copy +goes when its document is saved, reverted or closed, so copies still there after the app quits were left by a crash. +The copies live in the `recoveryFolder` preference's folder (default: `Data Recovery` beside the app's preferences; +none when the app runs with `VECTORCRAFT_NO_PREFS`) or, on the web, in browser storage. + +Each running app (each browser tab) keeps its copies in an area of its own (`/`): a sub-folder whose +`.lock` file it keeps locked while it runs, or on the web an area with a heartbeat it refreshes every minute. Only +areas nobody holds are offered: their lock is free, or their heartbeat is older than three intervals (at least three +minutes). Several apps running at once (agents' instances included) never see each other's copies as crash leftovers, +and an area being restored or discarded is held, so two apps launched together never both take it. + +`file.recovery.list` → `{copies: [{file, title, path, format, saved, open, running}], location}` (`open`: the copy of a +document open here; `running`: kept by another VectorCraft that is running). `file.recovery.restore {file?}` opens +copies left behind (default: all) as `" [Recovered]"`: modified, and Save asks where to save them (suggesting +their original file); the copy moves into this app's area. `file.recovery.discard {file?}` deletes them. Without a +folder or browser storage the commands are disabled and say how to set one. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"recoveryFolder","value":"/tmp/vc-recovery"}}} +{"name":"run_command","arguments":{"command":"file.recovery.save","params":{}}} +{"name":"run_command","arguments":{"command":"file.recovery.restore","params":{"file":"1759650000-1/Poster-1"}}} +``` From bf4614669e4b1b72f654267ef5cb7846ccc54d77 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 13:39:27 +0700 Subject: [PATCH 2/2] M4.38: native and .ai save options: each artboard to its own file, linked files, ICC profiles, PDF content - document.save / Save As / Save a Copy to a native or .ai file take separateArtboards + range (also write -. per artboard beside the master file, holding that artboard and the art touching it; the result lists every file), includeLinked (linked images keep their files' own pixels), embedProfiles (ICC profiles loaded from files that the document is tagged with; opening installs them where missing) and pdfCompatible (native: also carry a PDF of every artboard; .ai: false writes blank pages around the native document). .ai files also take compress (compression.compressText). - File > Save As to a native or .ai file shows the options dialog (VectorCraft Options) after the save panel: Save each artboard to a separate file (All or a range), Include Linked Files, Embed ICC Profiles, Create PDF-Compatible File, Use Compression, Preview; the web's Save As dialog shows them too. Save, Save a Copy and Save as Template reuse the options as last saved. - Format: optional "profiles" and "pdf" members (load_file, pdf_content), SaveOptions.include_linked/profiles/pdf; files without them are unchanged. - Changed test: tests_aisave::save_as_ai_writes_a_pdf_that_reopens_editable now confirms the options dialog Save As .ai shows before writing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 21 ++- crates/engine/src/cmd/fileio/load.rs | 16 +- crates/engine/src/cmd/fileio/mod.rs | 13 +- crates/engine/src/cmd/fileio/native.rs | 136 +++++++++++++- crates/engine/src/cmd/fileio/pdf.rs | 8 +- crates/engine/src/cmd/fileio/save.rs | 123 ++++++++++-- crates/engine/src/cmd/recovery.rs | 6 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_saveoptions.rs | 206 +++++++++++++++++++++ crates/format/src/lib.rs | 84 +++++++-- crates/format/tests/prop_format.rs | 22 ++- crates/format/tests/save_options.rs | 84 +++++++++ crates/ui-egui/src/dialogs/save_options.rs | 69 ++++++- crates/ui-egui/src/io.rs | 19 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_aisave.rs | 12 +- crates/ui-egui/src/tests_nativeoptions.rs | 103 +++++++++++ docs/control-protocol.md | 6 + docs/mcp.md | 23 +++ 19 files changed, 883 insertions(+), 72 deletions(-) create mode 100644 crates/engine/src/tests_saveoptions.rs create mode 100644 crates/format/tests/save_options.rs create mode 100644 crates/ui-egui/src/tests_nativeoptions.rs diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 70029d84d..fa0af5355 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -85,8 +85,9 @@ impl Encoded { } /// Every file to write for the destination `path` (a path or a file name): the file itself - /// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images - /// beside it under their own names, then the image maps as `{image stem}.html` or `.map`. + /// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard + /// among several, as Save's master file, is the file itself), then the linked images beside it + /// under their own names, then the image maps as `{image stem}.html` or `.map`. pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> { // Siblings keep the path's own separators (this runs on every platform and on the web). let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)]; @@ -94,15 +95,19 @@ impl Encoded { let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() { [(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))], files => { - let boards: Vec = files.iter().map(|(b, _)| b.unwrap_or(0)).collect(); + let boards: Vec = files.iter().filter_map(|(b, _)| *b).collect(); let file = std::path::Path::new(&path[dir.len()..]); let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); - super::artboard_file_names(doc, &boards) - .into_iter() - .zip(files) - .map(|(board, (_, bytes))| { - (sibling(&format!("{stem}{}{board}{ext}", self.joiner.unwrap_or("-"))), Cow::Borrowed(bytes.as_slice())) + let mut names = super::artboard_file_names(doc, &boards).into_iter(); + files + .iter() + .map(|(b, bytes)| { + let name = match b { + None => path.to_string(), + Some(_) => sibling(&format!("{stem}{}{}{ext}", self.joiner.unwrap_or("-"), names.next().unwrap_or_default())), + }; + (name, Cow::Borrowed(bytes.as_slice())) }) .collect() } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7c6e43cfb..dbef8d832 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -90,8 +90,8 @@ fn restore_or_import( let mut fallback = None; if let Some((intact, bytes)) = editing { let native = if intact { bytes() } else { None }; - match native.and_then(|b| vectorcraft_format::load(&b).ok()) { - Some(doc) => return Ok((doc, vec![], true)), + match native.and_then(|b| native_file(&b).ok()) { + Some((doc, _, warnings)) => return Ok((doc, warnings, true)), None => fallback = Some(if intact { EDITING_DAMAGED } else { EDITING_STALE }), } } @@ -102,6 +102,14 @@ fn restore_or_import( Ok((doc, warnings, false)) } +/// A native file's document and details, the colour profiles it carries installed where missing +/// (with a warning for each that can't be used). +pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec)> { + let f = vectorcraft_format::load_file(bytes).map_err(err)?; + let warnings = super::native::install_profiles(f.profiles); + Ok((f.doc, f.info, warnings)) +} + /// Read a file of any readable format (`name`: its file name or path, for the extension and title; /// a path's folder is where an SVG's relative image links are found). pub fn load(name: &str, bytes: &[u8]) -> Result { @@ -123,9 +131,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let mut converted = false; let (mut doc, warnings, restored) = match format.id { "vectorcraft" | "template" => { - let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?; + let (d, info, warnings) = native_file(bytes)?; converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; - (d, vec![], false) + (d, warnings, false) } "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 52b7587cd..4fdf8083a 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -43,6 +43,7 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub use export::export_source; use load::err; pub(crate) use load::import_svg; +pub(crate) use load::native_file; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; pub use native::with_compression_pref; @@ -360,7 +361,17 @@ pub const FORMATS: &[Format] = &[ options: svg::OPTIONS, }, Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS }, - reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false), + Format { + id: "ai", + label: "PDF-compatible .ai", + extensions: &["ai"], + mime: "application/pdf", + read: true, + // Saved (Save As), not exported. + write: false, + raster: false, + options: native::AI_OPTIONS, + }, reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false), Format { id: "png", diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index aef6dd8a0..dda393864 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -1,8 +1,13 @@ //! The native format's options: compression, the version to write (older apps read older -//! versions) and an embedded preview. +//! versions), an embedded preview, and the save options native and `.ai` files share: each +//! artboard to a separate file, Include Linked Files, Embed ICC Profiles and Create +//! PDF-Compatible File. + +use std::collections::BTreeMap; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_doc::Document; use vectorcraft_format::{PREVIEW_MAX, SaveOptions}; @@ -29,33 +34,146 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers", }, + SEPARATE_ARTBOARDS, + SEPARATE_RANGE, + INCLUDE_LINKED, + EMBED_PROFILES, + FormatOption { + name: "pdfCompatible", + ty: "boolean", + default: "false", + description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)", + }, +]; + +const SEPARATE_ARTBOARDS: FormatOption = FormatOption { + name: "separateArtboards", + ty: "boolean", + default: "false", + description: "also save each artboard (of range) to a file of its own beside this one, -., holding that artboard and the art on it", +}; +const SEPARATE_RANGE: FormatOption = FormatOption { + name: "range", + ty: "string", + default: "null", + description: "with separateArtboards: the artboards saved separately, 1-based such as \"1-3, 5\", or \"all\" (default)", +}; +const INCLUDE_LINKED: FormatOption = FormatOption { + name: "includeLinked", + ty: "boolean", + default: "false", + description: "keep the linked files' own pixels in the file (they stay linked), not just a preview: the document shows them in full where the files can't be found", +}; +const EMBED_PROFILES: FormatOption = FormatOption { + name: "embedProfiles", + ty: "boolean", + default: "true", + description: "carry the ICC profiles the document is tagged with that were loaded from files (Edit → Assign Profile), so the document keeps them where they aren't installed", +}; + +/// The save options of a `.ai` file (a PDF carrying the native document; its PDF options come on +/// top, see document.exportPdf). +pub const AI_OPTIONS: &[FormatOption] = &[ + SEPARATE_ARTBOARDS, + SEPARATE_RANGE, + INCLUDE_LINKED, + EMBED_PROFILES, + FormatOption { + name: "pdfCompatible", + ty: "boolean", + default: "true", + description: "write the artwork as PDF pages that other apps show; false keeps only the native document (smaller, faster; other apps show empty pages)", + }, + FormatOption { + name: "compress", + ty: "boolean", + default: "true", + description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)", + }, ]; #[derive(Default, Deserialize)] -#[serde(default)] +#[serde(default, rename_all = "camelCase")] struct NativeOptions { compress: Option, version: Option, preview: bool, + include_linked: bool, + embed_profiles: Option, + pdf_compatible: bool, } -/// The options in `p` for saving `doc`, checked (the preview is left to [`encode`]) → them and -/// whether to embed a preview. -pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, bool)> { +/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time). +pub(super) struct Extras { + preview: bool, + pdf: bool, +} + +/// The options in `p` for saving `doc`, checked, with the profiles to embed (the preview and PDF +/// are left to [`encode`]). +pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> { let o: NativeOptions = super::encode::options(f, p)?; let mut so = SaveOptions::for_doc(doc); so.compress = o.compress.unwrap_or(false); so.version = o.version.unwrap_or(vectorcraft_format::VERSION); + so.include_linked = o.include_linked; + if o.embed_profiles.unwrap_or(true) { + so.profiles = embedded_profiles(doc); + } so.check().map_err(|e| bad(cmd, e.to_string()))?; - Ok((so, o.preview)) + Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible })) } /// The `.vectorcraft` file of `doc` with the options in `p`. pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { - let (mut so, preview) = save_options(cmd, f, doc, p)?; - if preview { + let (mut so, extras) = save_options(cmd, f, doc, p)?; + if extras.preview { so.preview = preview_png(doc)?; } + if extras.pdf { + // Every artboard as PDF pages (no editing data: the file is the native document). + so.pdf = Some(super::pdf::encode(cmd, doc, &json!({ "range": "all", "preserveEditing": false }))?.0); + } + vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string())) +} + +/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in +/// ones are there wherever VectorCraft runs). +fn embedded_profiles(doc: &Document) -> BTreeMap> { + [&doc.color_profiles.rgb, &doc.color_profiles.cmyk] + .into_iter() + .flatten() + .filter_map(|name| cms::profile(name).filter(|p| !p.builtin)) + .filter_map(|p| Some((p.name.clone(), cms::icc_bytes(&p.name).ok()?.to_vec()))) + .collect() +} + +/// Most profiles read from one file. +const MAX_PROFILES: usize = 8; + +/// Make the profiles a file carries available (those not installed here), named as in the file → +/// a warning per profile that can't be used. +pub(super) fn install_profiles(profiles: BTreeMap>) -> Vec { + // A document is tagged with an RGB and a CMYK profile at most: more is junk. + profiles + .into_iter() + .take(MAX_PROFILES) + .filter(|(name, _)| cms::profile(name).is_none()) + .filter_map(|(name, icc)| { + cms::register_icc(&icc, Some(name.clone())).err().map(|e| format!("the colour profile “{name}” the file carries can't be used: {e}")) + }) + .collect() +} + +/// The native document a `.ai` file carries, with the options in `p` that apply to it +/// (`includeLinked`, `embedProfiles`; compact and uncompressed: the PDF compresses it). +pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.retain(|k, _| !matches!(k.as_str(), "compress" | "version" | "preview" | "pdfCompatible")); + } + let (mut so, _) = save_options(cmd, f, doc, &q)?; + so.pretty = false; vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string())) } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 49abd21c5..6aaf87390 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -197,11 +197,17 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + encode_carrying(cmd, doc, p, || Ok(vectorcraft_format::save(doc, false))) +} + +/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the +/// native document with its save options, whose pages may be left blank). +pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result>) -> Result<(Vec, Vec)> { let mut opts = options(cmd, doc, p)?; let mut warnings = vec![]; if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { - opts.native = Some(vectorcraft_format::save(doc, false)); + opts.native = Some(native()?); } else { opts.settings.preserve_editing = false; warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index cb6d284d9..11ff30908 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -13,7 +13,8 @@ use vectorcraft_doc::Document; use super::super::*; use super::{ - Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, write_file, + ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, + write_file, }; use crate::{DocState, Prefs}; @@ -198,10 +199,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { /// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General /// settings (`createLayers`…) and presets. fn reads_option(f: &Format, key: &str) -> bool { - match is_pdf(f) { - true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key), - false => f.options.iter().any(|o| o.name == key), - } + f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key))) } /// Formats that carry the whole native document: they lose nothing, and record links relative to @@ -251,8 +249,62 @@ pub struct SaveJob { params: Value, /// The document when the job was made: what counts as saved once the file is written. snapshot: Arc, + /// The artboards also saved to files of their own (`separateArtboards`). + boards: Vec, } +/// The artboards a native or `.ai` save also writes to files of their own: with +/// `separateArtboards`, those `range` names (default all). +fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + if !is_native(f) || !bool_or(p, "separateArtboards", false) { + return Ok(vec![]); + } + let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string); + let n = doc.artboards.len(); + let picked = ArtboardPick { range, ..Default::default() }.resolve(n).map_err(|e| bad(cmd, e))?; + Ok(picked.unwrap_or_else(|| (0..n).collect())) +} + +/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file): +/// objects whose bounds touch the artboard, in their layers; resources are kept. +fn artboard_doc(doc: &Document, i: usize) -> Document { + let mut d = doc.clone(); + let Some(a) = doc.artboards.get(i) else { return d }; + let r = a.rect; + d.artboards = vec![a.clone()]; + // It opens fitted to its artboard, not at the master file's view. + d.last_view = None; + for layer in &mut d.layers { + keep_on(layer, r); + } + d +} + +/// Keep in layer `n` (and its sublayers) only the objects touching `r`. +fn keep_on(n: &mut Arc, r: vectorcraft_geom::Rect) { + let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0; + if let Some(children) = Arc::make_mut(n).children_mut() { + children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches)); + for c in children.iter_mut().filter(|c| c.is_layer()) { + keep_on(c, r); + } + } +} + +/// `doc` with empty layers: the pages of a `.ai` file saved without PDF content. +fn blank_pages(doc: &Document) -> Document { + let mut d = doc.clone(); + for layer in &mut d.layers { + if let Some(children) = Arc::make_mut(layer).children_mut() { + children.clear(); + } + } + d +} + +/// Why a `.ai` file saved without PDF content looks empty elsewhere. +const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; + /// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the /// document's own). Bad options fail here, before anything is encoded. pub fn save_job(s: &mut Session, plan: SavePlan) -> Result { @@ -285,12 +337,20 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result Result { - let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc); - let mut enc = if plan.format.id == "ai" { - let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?; + let mut enc = self.encode_one(&self.doc)?; + for &b in &self.boards { + let one = self.encode_one(&artboard_doc(&self.doc, b))?; + enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); + } + let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true); + let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string())); + enc.warnings.splice(0..0, notes); + Ok(enc) + } + + /// Encode one file of `doc` in the job's format. + fn encode_one(&self, doc: &Document) -> Result { + let (cmd, f) = (self.plan.mode.command(), self.plan.format); + let enc = if f.id == "ai" { + // The pages (blank without PDF content), carrying the native document. + let pages = doc.without_edit_modes(); + let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) }; + let native = || super::native::ai_native(cmd, f, doc, &self.params); + let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?; Encoded { warnings, ..Encoded::one(bytes) } } else { - encode_all(doc, plan.format.id, &self.params)? + encode_all(doc, f.id, &self.params)? }; if enc.files.len() != 1 { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } - enc.warnings.splice(0..0, fidelity_warning(plan.format)); Ok(enc) } - /// Encode the file and write it with `write` (the file, then the images an SVG links to, - /// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: - /// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`. + /// Encode the file and write it with `write` (the file, then its artboards' files and the + /// images an SVG links to, beside it). Without a path → `{dataBase64, bytes, format, name, + /// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several), + /// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?: + /// [path…], linked?: [path…]}`. pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let (plan, doc) = (&self.plan, &*self.doc); let mut enc = self.encode()?; @@ -342,7 +421,11 @@ impl SaveJob { } let bytes = files.first().map_or(0, |f| f.1.len()); let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra); - if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) { + let (main, linked) = files.split_at(enc.files.len().min(files.len())); + if main.len() > 1 { + out["files"] = main.iter().map(|(p, _)| json!(p)).collect(); + } + if !linked.is_empty() { out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect(); } Ok(out) @@ -420,7 +503,9 @@ fn format_options(s: &mut Session, p: &Value) -> Result { const C: &str = "file.formatOptions"; let st = s.active(); let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft"); - let f = format(id).filter(|f| f.write).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; + let f = format(id) + .filter(|f| f.write || SAVE_FORMATS.contains(&f.id)) + .ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); @@ -443,7 +528,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index f3451e236..c0a511833 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -735,7 +735,7 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?; let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default(); let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default(); - let mut doc = vectorcraft_format::load(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?; + let (mut doc, _, warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?; let area = claim(s, store)?; let mut taken: Vec = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect(); taken.extend(open_copies(s)); @@ -761,7 +761,7 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); let title = st.title(); - Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path }), links.to_json())) + Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json())) } fn restore(s: &mut Session, p: &Value) -> Result { @@ -823,7 +823,7 @@ pub fn specs() -> Vec { "Restore Recovered Documents", [], None, - "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", has_store, restore ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7bc0c5b5d..fd981a495 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1205,6 +1205,8 @@ mod tests_registration; #[cfg(test)] mod tests_save; #[cfg(test)] +mod tests_saveoptions; +#[cfg(test)] mod tests_scalestrokes; #[cfg(test)] mod tests_slices; diff --git a/crates/engine/src/tests_saveoptions.rs b/crates/engine/src/tests_saveoptions.rs new file mode 100644 index 000000000..f0adc4322 --- /dev/null +++ b/crates/engine/src/tests_saveoptions.rs @@ -0,0 +1,206 @@ +//! Native and `.ai` save options: each artboard to a separate file (a master file too), Include +//! Linked Files, Embed ICC Profiles, Create PDF-Compatible File and the `.ai` Use Compression. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::cms; + +use super::*; +use crate::tests_links::{Folder, png}; + +/// Three artboards (named A, B and C) with a rectangle on each. +fn three_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "name": "A"})).unwrap(); + for (i, name) in ["B", "C"].iter().enumerate() { + let x = 200.0 * (i + 1) as f64; + s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100, "name": name})).unwrap(); + } + for x in [10, 210, 410] { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap(); + } + s +} + +fn objects(d: &vectorcraft_doc::Document) -> usize { + d.layers.iter().map(|l| l.count() - 1).sum() +} + +fn bytes_of(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v.as_str().expect("base64")).unwrap() +} + +#[test] +fn three_artboards_give_four_files() { + let dir = Folder::new("separate"); + let mut s = three_boards(); + let path = dir.file("Poster.vectorcraft"); + let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files, [path.clone(), dir.file("Poster-A.vectorcraft"), dir.file("Poster-B.vectorcraft"), dir.file("Poster-C.vectorcraft")]); + // The master file has everything; each artboard's file that artboard and its art. + let master = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!((master.artboards.len(), objects(&master)), (3, 3)); + for (f, name, x) in [(files[1], "A", 0.0), (files[2], "B", 200.0), (files[3], "C", 400.0)] { + let d = vectorcraft_format::load(&std::fs::read(f).unwrap()).unwrap(); + assert_eq!((d.artboards.len(), d.artboards[0].name.as_str(), d.artboards[0].rect.x0), (1, name, x)); + assert_eq!(objects(&d), 1, "{name}"); + } + // The document takes the master file; Save remembers the choice. + let st = s.doc().unwrap(); + assert_eq!((st.path.as_deref(), st.is_dirty()), (Some(path.as_str()), false)); + // A range: the master and two artboards. + let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true, "range": "2-3"})).unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), 3); + assert!(s.execute("document.save", &json!({"path": path, "separateArtboards": true, "range": "4"})).is_err(), "no fourth artboard"); + // No path: the bytes of every file. + let r = s.execute("file.saveCopy", &json!({"separateArtboards": true})).unwrap(); + let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Poster copy.vectorcraft", "Poster copy-A.vectorcraft", "Poster copy-B.vectorcraft", "Poster copy-C.vectorcraft"]); + assert!(vectorcraft_format::sniff(&bytes_of(&r["files"][3]["dataBase64"]))); +} + +#[test] +fn ai_files_save_each_artboard_too_and_reopen_editable() { + let dir = Folder::new("separate-ai"); + let mut s = three_boards(); + let path = dir.file("Set.ai"); + let r = s.execute("file.saveAs", &json!({"path": path, "separateArtboards": true, "range": "1, 3"})).unwrap(); + assert_eq!(r["files"], json!([path, dir.file("Set-A.ai"), dir.file("Set-C.ai")])); + let r = s.execute("document.open", &json!({"path": dir.file("Set-C.ai")})).unwrap(); + assert_eq!(r["restored"], true); + let d = &s.doc().unwrap().doc; + assert_eq!((d.artboards.len(), d.artboards[0].name.as_str(), objects(d)), (1, "C", 1)); +} + +#[test] +fn a_linked_pngs_bytes_are_embedded() { + let dir = Folder::new("include-linked"); + let pic = dir.file("photo.png"); + let bytes = png(400, 300, [200, 30, 30]); + std::fs::write(&pic, &bytes).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap(); + s.execute("file.place", &json!({"path": pic, "link": true})).unwrap(); + let images = |v: &Value| -> Value { + let text = bytes_of(&v["dataBase64"]); + serde_json::from_slice::(&text).unwrap()["images"].clone() + }; + // By default only the preview. + let plain = images(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()); + let (_, image) = plain.as_object().unwrap().iter().next().unwrap(); + assert_eq!(image["proxy"], true); + // Include Linked Files: the file's own bytes. + let r = s.execute("file.saveCopy", &json!({"compress": false, "includeLinked": true})).unwrap(); + let full = images(&r); + let (_, image) = full.as_object().unwrap().iter().next().unwrap(); + assert!(image.get("proxy").is_none()); + assert_eq!(vectorcraft_format::base64_decode(image["data"].as_str().unwrap()).unwrap(), bytes); + // With the file gone the document still shows the full pixels. + std::fs::remove_file(&pic).unwrap(); + let r = s.execute("document.open", &json!({"name": "copy.vectorcraft", "dataBase64": r["dataBase64"]})).unwrap(); + assert_eq!(r["missingLinks"].as_array().unwrap().len(), 1); + let d = &s.doc().unwrap().doc; + let blob = d.images.values().next().unwrap(); + assert_eq!(blob.bytes.as_slice(), bytes.as_slice()); + let mut linked = 0; + d.visit_images(|_, im| linked += usize::from(im.link.is_some())); + assert_eq!(linked, 1, "still linked"); +} + +#[test] +fn an_embedded_profile_is_restored() { + let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); + let icc = cms::icc_bytes(&builtin.name).unwrap(); + let name = format!("Studio RGB {}", std::process::id()); + cms::register_icc(&icc, Some(name.clone())).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + Arc::make_mut(&mut s.doc_mut().unwrap().doc).color_profiles.rgb = Some(name.clone()); + let file = bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"]); + let v: Value = serde_json::from_slice(&file).unwrap(); + assert_eq!(vectorcraft_format::base64_decode(v["profiles"][&name]["data"].as_str().unwrap()).unwrap(), icc.to_vec()); + // Not embedded when asked not to; built-in profiles never are (they are everywhere). + let none: Value = + serde_json::from_slice(&bytes_of(&s.execute("file.saveCopy", &json!({"compress": false, "embedProfiles": false})).unwrap()["dataBase64"])) + .unwrap(); + assert!(none.get("profiles").is_none()); + // Elsewhere (a profile name this machine lacks): opening installs it and the document keeps it. + let elsewhere = format!("Studio RGB elsewhere {}", std::process::id()); + let moved = String::from_utf8(file).unwrap().replace(&name, &elsewhere); + assert!(cms::profile(&elsewhere).is_none()); + let r = + s.execute("document.open", &json!({"name": "moved.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(moved.as_bytes())})).unwrap(); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); + assert_eq!(cms::profile(&elsewhere).map(|p| p.kind), Some(cms::ProfileKind::Rgb)); + assert_eq!(s.doc().unwrap().doc.color_profiles.rgb.as_deref(), Some(elsewhere.as_str())); + // Damaged profile data opens the document with a warning. + let broken = format!("Studio RGB broken {}", std::process::id()); + let mut v: Value = serde_json::from_str(&moved.replace(&elsewhere, &broken)).unwrap(); + v["profiles"][&broken]["data"] = json!("AAAA"); + let r = s + .execute( + "document.open", + &json!({"name": "b.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&serde_json::to_vec(&v).unwrap())}), + ) + .unwrap(); + assert!(r["warnings"][0].as_str().unwrap().contains(&broken), "{r}"); +} + +#[test] +fn pdf_compatible_files_carry_a_pdf() { + let mut s = three_boards(); + let file = bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": true})).unwrap()["dataBase64"]); + let pdf = vectorcraft_format::pdf_content(&file).unwrap(); + assert!(pdf.starts_with(b"%PDF")); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + assert_eq!(info["pages"], 3); + assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none()); +} + +#[test] +fn ai_without_pdf_content_and_compression() { + let mut s = three_boards(); + let ai = |s: &mut Session, p: Value| -> (Vec, Value) { + let mut p = p; + p["format"] = json!("ai"); + let r = s.execute("file.saveCopy", &p).unwrap(); + (bytes_of(&r["dataBase64"]), r["warnings"].clone()) + }; + let (full, _) = ai(&mut s, json!({})); + let (blank, warnings) = ai(&mut s, json!({"pdfCompatible": false})); + assert!(warnings.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("without PDF content")), "{warnings}"); + // The pages are empty (an import of a page finds no art), the document comes back whole. + let page = |s: &mut Session, b: &[u8]| { + s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(b), "pages": "1"})).unwrap(); + objects(&s.doc().unwrap().doc) + }; + assert!(page(&mut s, &full) > 0); + assert_eq!(page(&mut s, &blank), 0); + s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(&blank)})).unwrap(); + assert_eq!(objects(&s.doc().unwrap().doc), 3); + // Use Compression off writes the content uncompressed: a larger file. + let mut s = three_boards(); + let (packed, _) = ai(&mut s, json!({"compress": true})); + let (loose, _) = ai(&mut s, json!({"compress": false})); + assert!(loose.len() > packed.len(), "{} vs {}", loose.len(), packed.len()); +} + +#[test] +fn format_options_list_the_save_options() { + let mut s = three_boards(); + let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap(); + for (k, value) in [("separateArtboards", false), ("includeLinked", false), ("embedProfiles", true), ("pdfCompatible", true), ("compress", true)] { + assert_eq!(v["options"][k]["value"], value, "{k}"); + } + let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true)); + // Saved with them, a document remembers them for Save. + let dir = Folder::new("remember"); + let path = dir.file("r.vectorcraft"); + s.execute("document.save", &json!({"path": path, "includeLinked": true})).unwrap(); + let v = s.execute("file.formatOptions", &json!({})).unwrap(); + assert_eq!(v["options"]["includeLinked"]["value"], true); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5c50878bb..423b085be 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -5,15 +5,20 @@ //! ```json //! { "format": "vectorcraft", "version": 3, "generator": "VectorCraft 0.1.0", //! "preview": { "mime": "image/png", "data": "" }, +//! "profiles": { "": { "mime": "application/vnd.iccprofile", "data": "" } }, +//! "pdf": { "mime": "application/pdf", "data": "" }, //! "document": { …vectorcraft_doc::Document… }, //! "images": { "": { "mime": "image/png", "data": "" } } } //! ``` //! It is lossless for everything in the document model; foreign data lives in `document.unknown` //! and document keys a newer version added are kept (`Document::extra`). Only the images the //! document uses are written. `preview` (optional) is a PNG of the first artboard, at most -//! [`PREVIEW_MAX`] pixels on its longer side, for file browsers. An image only linked images show -//! is saved as its low-resolution preview (`"proxy": true`); the engine reads the linked file again -//! when the document opens. +//! [`PREVIEW_MAX`] pixels on its longer side, for file browsers. `profiles` (optional, Embed ICC +//! Profiles) holds the ICC files of colour profiles the document is tagged with, for machines that +//! don't have them ([`load_file`] returns them). `pdf` (optional, Create PDF-Compatible File) is a +//! PDF of every artboard for apps that read PDF ([`pdf_content`]). An image only linked images +//! show is saved as its low-resolution preview (`"proxy": true`), unless saved with Include Linked +//! Files; the engine reads the linked file again when the document opens. //! //! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as //! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour @@ -95,6 +100,8 @@ struct File { document: Document, #[serde(default)] images: BTreeMap, + #[serde(default)] + profiles: BTreeMap, } /// How [`save_with`] writes a document. @@ -108,14 +115,23 @@ pub struct SaveOptions { pub version: u32, /// A PNG preview to embed (at most [`PREVIEW_MAX`] pixels on its longer side). pub preview: Option>, + /// Include Linked Files: linked images keep the file's own bytes, not just their preview. + pub include_linked: bool, + /// Embed ICC Profiles: ICC files by profile name. + pub profiles: BTreeMap>, + /// Create PDF-Compatible File: a PDF of the document to carry. + pub pdf: Option>, } impl Default for SaveOptions { fn default() -> Self { - Self { pretty: false, compress: false, version: VERSION, preview: None } + Self { pretty: false, compress: false, version: VERSION, preview: None, include_linked: false, profiles: BTreeMap::new(), pdf: None } } } +/// The MIME type of embedded ICC profiles. +pub const ICC_MIME: &str = "application/vnd.iccprofile"; + impl SaveOptions { /// Can a file be written this way (a version [`save_with`] writes, compressed only for readers /// that open compressed files)? @@ -177,21 +193,33 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError .iter() .filter(|(k, _)| used.contains(k.as_str())) .map(|(k, b)| { - let image = match b.proxy.as_ref().filter(|_| linked.contains(k)) { + // Include Linked Files keeps the file's bytes whenever they are loaded. + let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy()); + let image = match b.proxy.as_ref().filter(|_| preview_only) { Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true }, None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false }, }; (k.as_str(), image) }) .collect(); - let size = body.len() + images.values().map(|i| i.data.len()).sum::() + 256; + let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), proxy: false }; + let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), blob(ICC_MIME, icc))).collect(); + let pdf = o.pdf.as_deref().map(|p| blob("application/pdf", p)); + let carried = profiles.values().chain(&pdf).map(|i| i.data.len()).sum::(); + let size = body.len() + images.values().map(|i| i.data.len()).sum::() + carried + 256; let mut w = Envelope { out: Vec::with_capacity(size), pretty }; // Older apps only know the name from before the rename. w.field("format", &if o.version < VERSION { LEGACY_EXTENSION } else { EXTENSION })?; w.field("version", &o.version)?; w.field("generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; if let Some(png) = &o.preview { - w.field("preview", &Image { mime: "image/png".into(), data: base64_encode(png), proxy: false })?; + w.field("preview", &blob("image/png", png))?; + } + if !profiles.is_empty() { + w.field("profiles", &profiles)?; + } + if let Some(pdf) = &pdf { + w.field("pdf", pdf)?; } w.raw("document", &body); w.field("images", &images)?; @@ -295,6 +323,15 @@ pub fn load(bytes: &[u8]) -> Result { load_info(bytes).map(|(doc, _)| doc) } +/// A native file read: its document, what it says about itself and the colour profiles it carries. +#[derive(Debug)] +pub struct NativeFile { + pub doc: Document, + pub info: FileInfo, + /// ICC files by profile name (saved with Embed ICC Profiles; undecodable ones are left out). + pub profiles: BTreeMap>, +} + /// What a native file says about itself besides its document. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct FileInfo { @@ -313,6 +350,11 @@ impl FileInfo { /// [`load`], also telling the file's version and name. pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { + load_file(bytes).map(|f| (f.doc, f.info)) +} + +/// [`load`], with everything else the file carries. +pub fn load_file(bytes: &[u8]) -> Result { let text = unpack(bytes)?; let f: File = serde_json::from_slice(&text).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; if f.format != EXTENSION && f.format != LEGACY_EXTENSION { @@ -338,18 +380,30 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { // Saved before v3: the assigned profiles move out of `unknown`. doc.migrate_color_profiles(); doc.fix_next_id(); - Ok((doc, FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION })) + let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect(); + Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) +} + +/// The members of a native file besides its document (which is skipped, not decoded). +#[derive(Deserialize)] +struct Head { + preview: Option, + pdf: Option, +} + +fn head(bytes: &[u8]) -> Option { + serde_json::from_slice(&unpack(bytes).ok()?).ok() } /// The preview PNG embedded in a native file (`None`: it has none, or isn't a native file). pub fn preview(bytes: &[u8]) -> Option> { - #[derive(Deserialize)] - struct Head { - preview: Option, - } - let text = unpack(bytes).ok()?; - let head: Head = serde_json::from_slice(&text).ok()?; - base64_decode(&head.preview?.data) + base64_decode(&head(bytes)?.preview?.data) +} + +/// The PDF a native file saved with Create PDF-Compatible File carries (`None`: it has none, or +/// isn't a native file). +pub fn pdf_content(bytes: &[u8]) -> Option> { + base64_decode(&head(bytes)?.pdf?.data) } /// Does this look like a `.vectorcraft` file (compressed or not)? diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index ca591d1ea..639af5389 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -5,7 +5,7 @@ use proptest::prelude::*; use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind}; -use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, preview, save, save_with, sniff}; +use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, load_file, pdf_content, preview, save, save_with, sniff}; use vectorcraft_geom::Affine; use vectorcraft_testkit::fixtures; use vectorcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq}; @@ -113,6 +113,26 @@ proptest! { check_document(&d).map_err(TestCaseError::fail)?; } } + + /// Mutating a file carrying ICC profiles and a PDF (save options) never panics the readers. + #[test] + fn load_mutated_file_with_profiles_never_panics(cut in 0usize..3000, flips in prop::collection::vec((0usize..3000, any::()), 0..8)) { + let o = SaveOptions { + profiles: [("Studio RGB".to_string(), vec![1, 2, 3]), ("Press".to_string(), vec![])].into(), + pdf: Some(b"%PDF-1.7".to_vec()), + ..SaveOptions::default() + }; + let mut bytes = save_with(&Document::new(100.0, 100.0), &o).unwrap(); + for (i, b) in flips { + let n = bytes.len(); + bytes[i % n] = b; + } + bytes.truncate(cut.min(bytes.len())); + let _ = pdf_content(&bytes); + if let Ok(f) = load_file(&bytes) { + check_document(&f.doc).map_err(TestCaseError::fail)?; + } + } } #[test] diff --git a/crates/format/tests/save_options.rs b/crates/format/tests/save_options.rs new file mode 100644 index 000000000..766f4f670 --- /dev/null +++ b/crates/format/tests/save_options.rs @@ -0,0 +1,84 @@ +//! The native save options of the file itself: Include Linked Files, embedded ICC profiles and the +//! PDF a PDF-compatible file carries, each round-tripping (and ignored by readers that don't +//! need them). +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::links::LinkInfo; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_format::{ICC_MIME, SaveOptions, load, load_file, pdf_content, save_with}; +use vectorcraft_geom::Affine; + +fn json_of(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() +} + +/// A document showing image `k` linked to a file; the blob holds the file's bytes and a preview. +fn linked_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let im = ImageObject { + key: "k".into(), + width: 2, + height: 2, + xf: Affine::IDENTITY, + link: Some(LinkInfo::new("/art/photo.png")), + placement: Default::default(), + }; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); + blob.proxy = Some(Arc::new(vec![9, 9])); + d.images.insert("k".into(), blob); + d +} + +#[test] +fn include_linked_files_keeps_the_files_bytes() { + let d = linked_doc(); + let plain = save_with(&d, &SaveOptions::default()).unwrap(); + let image = &json_of(&plain)["images"]["k"]; + assert_eq!(image["proxy"], true, "only the preview by default"); + let full = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); + let image = &json_of(&full)["images"]["k"]; + assert!(image.get("proxy").is_none()); + let back = load(&full).unwrap(); + assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 4, 5, 6]); + assert!(!back.images["k"].is_proxy()); + // Only the preview loaded (the file was never found): the preview is all there is to write. + let mut missing = linked_doc(); + let preview = Arc::new(vec![9, 9]); + missing.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }); + let out = save_with(&missing, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); + assert_eq!(json_of(&out)["images"]["k"]["proxy"], true); +} + +#[test] +fn embedded_profiles_and_pdf_round_trip() { + let mut d = Document::new(100.0, 100.0); + d.color_profiles.rgb = Some("Studio RGB".into()); + let o = SaveOptions { + profiles: [("Studio RGB".to_string(), vec![0, 1, 2, 3, 250])].into(), + pdf: Some(b"%PDF-1.7 fake".to_vec()), + compress: true, + ..SaveOptions::default() + }; + let bytes = save_with(&d, &o).unwrap(); + let f = load_file(&bytes).unwrap(); + assert_eq!(f.profiles["Studio RGB"], vec![0, 1, 2, 3, 250]); + assert_eq!(f.doc.color_profiles.rgb.as_deref(), Some("Studio RGB")); + assert_eq!(pdf_content(&bytes).unwrap(), b"%PDF-1.7 fake"); + // The plain file says what they are; a file without them has neither member. + let plain = save_with(&d, &SaveOptions { compress: false, ..o }).unwrap(); + let v = json_of(&plain); + assert_eq!((v["profiles"]["Studio RGB"]["mime"].as_str(), v["pdf"]["mime"].as_str()), (Some(ICC_MIME), Some("application/pdf"))); + let none = save_with(&d, &SaveOptions::default()).unwrap(); + assert!(json_of(&none).get("profiles").is_none() && pdf_content(&none).is_none()); + assert!(load_file(&none).unwrap().profiles.is_empty()); + // Damaged profile data is left out rather than failing the open. + let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1); + assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty()); +} diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index e8f4b053b..deae2061c 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -5,11 +5,14 @@ //! //! Fields: `__action` (the save command it finishes), `__pick` (file name and format //! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`). +//! Native and `.ai` files show their save options (VectorCraft Options after Save As): each +//! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files, +//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. use std::sync::OnceLock; use serde_json::{Map, Value, json}; -use vectorcraft_engine::cmd::fileio::{self, Format, FormatOption, SAVE_FORMATS, SaveMode}; +use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format, FormatOption, SAVE_FORMATS, SaveMode}; use super::{DialogResult, DialogSpec, form}; use crate::state::Dialog; @@ -52,12 +55,58 @@ fn own_dialog(f: &Format) -> bool { matches!(f.id, "svg" | "svgz" | "pdf") } +/// Does `f` save each artboard to a separate file on request (native and `.ai` files)? Its +/// `range` then shows under that checkbox. +fn separates(f: &Format) -> bool { + f.options.iter().any(|o| o.name == "separateArtboards") +} + /// The options the dialog shows: arrays and objects are for agents, and a page range replaces the /// single artboard choice; none for a format with its own dialog. fn shown(f: &Format) -> impl Iterator { let range = f.options.iter().any(|o| o.name == "range"); + let separate = separates(f); let options = if own_dialog(f) { &[][..] } else { f.options }; - options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard"))) + options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard") || (separate && o.name == "range"))) +} + +/// An option's label: the save options as the reference app's options dialog names them, the +/// others from their names. +fn label(o: &FormatOption) -> String { + match o.name { + "separateArtboards" => "Save each artboard to a separate file".into(), + "includeLinked" => "Include Linked Files".into(), + "embedProfiles" => "Embed ICC Profiles".into(), + "pdfCompatible" => "Create PDF-Compatible File".into(), + "compress" => "Use Compression".into(), + _ => form::humanize(o.name), + } +} + +/// Every artboard (the All choice under Save each artboard to a separate file): `all` when set, +/// else no range given. +fn all_artboards(d: &Dialog) -> bool { + d.fields.get("all").and_then(Value::as_bool).unwrap_or_else(|| d.str("range").trim().is_empty()) +} + +/// All or Range, under Save each artboard to a separate file (enabled while it is on). +fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog) { + ui.label(""); + ui.add_enabled_ui(d.bool("separateArtboards"), |ui| { + ui.horizontal(|ui| { + ui.add_space(22.0); + let mut all = all_artboards(d); + if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() { + d.fields.insert("all".into(), json!(all)); + } + let mut range = d.str("range"); + let field = egui::TextEdit::singleline(&mut range).desired_width(90.0).hint_text("1-3, 5"); + if ui.add_enabled(!all, field).changed() { + d.fields.insert("range".into(), json!(range)); + } + }); + }); + ui.end_row(); } /// The Save As format menu (labels in [`SAVE_FORMATS`] order). @@ -92,7 +141,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// One option: a checkbox, an artboard menu, a number or a text field. fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) { let t = Tokens::get(ui.ctx()); - let label = form::humanize(o.name); + let label = label(o); let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null); if o.ty == "boolean" { ui.label(""); @@ -101,6 +150,9 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma d.fields.insert(o.name.into(), json!(on)); } ui.end_row(); + if o.name == "separateArtboards" { + artboard_range(ui, d); + } return; } if o.ty == "string" { @@ -154,11 +206,20 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { return Err("name the file".into()); } let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path }; - let options: Map = f + let mut options: Map = f .options .iter() .filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone()))) .collect(); + if separates(f) { + if !d.bool("separateArtboards") || all_artboards(d) { + options.remove("range"); + } else { + // A bad range keeps the dialog open. + let n = app.session.active().map_or(0, |s| s.doc.artboards.len()); + ArtboardPick { range: Some(d.str("range")), ..Default::default() }.resolve(n)?; + } + } app.ui.dialog = None; match SaveMode::of(&d.str("__action")) { Some(mode) if own_dialog(f) => io::ask_format_options(app, mode, f, &path), diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 04f1d8e07..62373fffb 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -194,8 +194,8 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result, +/// Options, the Save PDF dialog, or the save options dialog: native and `.ai` files on Save As +/// only), and so does Save As or Save a Copy to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: , /// path?}` while a dialog is open. pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { remember_view(app); @@ -222,14 +222,25 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo o.insert("path".into(), json!(with_save_extension(&picked, first.format))); } let chosen = plan(app, mode, &q)?; - // Native saves don't stop to ask: Use Compression decides, and options can be passed. - if ask && !chosen.format.options.is_empty() && !matches!(chosen.format.id, "vectorcraft" | "template") { + if ask && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); return ask_format_options(app, mode, chosen.format, &path); } write_plan(app, chosen) } +/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai` +/// files only on Save As (the options dialog after the save panel); Save, a Copy and templates +/// reuse the remembered options or Use Compression. +fn asks_options(mode: SaveMode, f: &Format) -> bool { + !f.options.is_empty() + && match f.id { + "vectorcraft" | "ai" => mode == SaveMode::SaveAs, + "template" => false, + _ => true, + } +} + /// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)? fn has_options(p: &Value) -> bool { ["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad05e4806..4afbbe192 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,8 @@ mod tests_docsetup; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_nativeoptions; +#[cfg(test)] mod tests_overprint; #[cfg(test)] mod tests_paintchips; diff --git a/crates/ui-egui/src/tests_aisave.rs b/crates/ui-egui/src/tests_aisave.rs index df992bfde..b6af7a31b 100644 --- a/crates/ui-egui/src/tests_aisave.rs +++ b/crates/ui-egui/src/tests_aisave.rs @@ -1,5 +1,6 @@ -//! Save As .ai in the app: a PDF-compatible file written without an options dialog, which reopens -//! as the document and saves as .ai again; Save a Copy as .ai leaves the document's path alone. +//! Save As .ai in the app: the options dialog, then a PDF-compatible file, which reopens as the +//! document and saves as .ai again (without asking); Save a Copy as .ai leaves the document's path +//! alone. use serde_json::{Value, json}; @@ -8,8 +9,13 @@ use crate::tests_svg::app; #[test] fn save_as_ai_writes_a_pdf_that_reopens_editable() { let (mut app, written) = app("/tmp/art.ai"); + // Save As asks for the .ai options first (M4.38), then writes. let r = app.run("file.saveAs", Value::Null).unwrap(); - assert_eq!(r["path"], "/tmp/art.ai", "no options dialog: {r}"); + assert_eq!(r["pending"], "saveOptions", "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("format").as_str(), d.str("path").as_str(), d.bool("pdfCompatible")), ("ai", "/tmp/art.ai", true)); + let r = crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(r["path"], "/tmp/art.ai", "{r}"); assert!(app.ui.dialog.is_none()); let ai = written.borrow()[0].1.clone(); assert!(ai.starts_with(b"%PDF")); diff --git a/crates/ui-egui/src/tests_nativeoptions.rs b/crates/ui-egui/src/tests_nativeoptions.rs new file mode 100644 index 000000000..aec94be4b --- /dev/null +++ b/crates/ui-egui/src/tests_nativeoptions.rs @@ -0,0 +1,103 @@ +//! VectorCraft Options after Save As: each artboard to a separate file (All or a range), Include +//! Linked Files, Embed ICC Profiles, Create PDF-Compatible File, Use Compression and Preview; the +//! web's Save As dialog shows them too. + +use serde_json::{Value, json}; + +use crate::tests_svg::app; +use crate::{dialogs, theme}; + +/// One headless frame of the dialog layer. +fn frame(app: &mut crate::VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +/// Add artboards B and C, a rectangle on each. +fn three_boards(app: &mut crate::VectorcraftApp) { + for (x, name) in [(200, "B"), (400, "C")] { + app.run("artboard.new", json!({"x": x, "y": 0, "width": 100, "height": 80, "name": name})).unwrap(); + app.run("shape.rectangle", json!({"x": x + 10, "y": 10, "width": 20, "height": 20})).unwrap(); + } +} + +#[test] +fn save_as_native_asks_for_its_options_and_writes_every_artboard() { + let (mut app, written) = app("/tmp/doc.vectorcraft"); + three_boards(&mut app); + let r = app.run("file.saveAs", Value::Null).unwrap(); + assert_eq!(r["pending"], "saveOptions"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("format").as_str(), d.bool("separateArtboards"), d.bool("embedProfiles"), d.bool("pdfCompatible")), + ("vectorcraft", false, true, false) + ); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "drawing keeps it open"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("separateArtboards".into(), json!(true)); + d.fields.insert("all".into(), json!(false)); + d.fields.insert("range".into(), json!("9")); + frame(&mut app); + assert!(dialogs::confirm(&mut app).is_err(), "a bad range keeps the dialog open"); + assert!(app.ui.dialog.is_some()); + app.ui.dialog.as_mut().unwrap().fields.insert("range".into(), json!("2-3")); + let r = dialogs::confirm(&mut app).unwrap(); + let names: Vec = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["/tmp/doc.vectorcraft", "/tmp/doc-B.vectorcraft", "/tmp/doc-C.vectorcraft"], "{r}"); + let st = app.session.active().unwrap(); + assert_eq!((st.path.as_deref(), st.is_dirty()), (Some("/tmp/doc.vectorcraft"), false)); + // Save writes them again with the same options, without asking. + app.run("shape.ellipse", json!({"x": 50, "y": 40, "width": 10, "height": 10})).unwrap(); + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 6); + // All: every artboard. + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("separateArtboards"), "the options as last saved"); + d.fields.insert("all".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(written.borrow().len(), 10); +} + +#[test] +fn save_a_copy_and_save_dont_ask_and_options_reach_the_file() { + let (mut app, written) = app("/tmp/plain.vectorcraft"); + // Save of an untitled document goes straight to the file, as before. + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 1); + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("pdfCompatible".into(), json!(true)); + d.fields.insert("compress".into(), json!(false)); + dialogs::confirm(&mut app).unwrap(); + let bytes = written.borrow()[1].1.clone(); + assert!(vectorcraft_format::pdf_content(&bytes).is_some_and(|p| p.starts_with(b"%PDF"))); + assert!(!vectorcraft_format::is_compressed(&bytes)); +} + +#[test] +fn the_web_save_as_dialog_shows_the_native_options() { + let written = crate::tests_svg::Written::default(); + let w = written.clone(); + let services = crate::Services { + download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + three_boards(&mut app); + app.run("file.saveAs", Value::Null).unwrap(); + frame(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("__pick") && d.fields.contains_key("separateArtboards")); + d.fields.insert("separateArtboards".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + let names: Vec = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["Untitled-1.vectorcraft", "Untitled-1-Artboard-1.vectorcraft", "Untitled-1-B.vectorcraft", "Untitled-1-C.vectorcraft"]); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 99b91dae7..4b0e15a4b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -346,3 +346,9 @@ saved}]`, from `file.recovery.list`) and `discard`. `ui.dialog.confirm` restores each, opening " [Recovered]" documents), `discard: true` then confirm deletes them, `ui.dialog.cancel` keeps them for the next launch. While copies are written in the background `ui.inspect` lists them under `background` ("Saving recovery data for "). + +Native save options: File → Save As to a native or `.ai` file opens the `saveOptions` dialog after the save panel +(heading "VectorCraft Options" or "PDF-compatible .ai Options"). Fields: `separateArtboards`, `all` (true: every +artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for +native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the +artboards' files. diff --git a/docs/mcp.md b/docs/mcp.md index 906c8d386..f6f40d910 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1291,3 +1291,26 @@ folder or browser storage the commands are disabled and say how to set one. {"name":"run_command","arguments":{"command":"file.recovery.save","params":{}}} {"name":"run_command","arguments":{"command":"file.recovery.restore","params":{"file":"1759650000-1/Poster-1"}}} ``` + +## Native save options + +`document.save` / `file.saveAs` / `file.saveCopy` to a native (`.vectorcraft`, `.vctemplate`) or `.ai` file take, flat +or in `options` (`file.formatOptions {format}` lists them with their values): + +- `separateArtboards: true` also writes each artboard of `range` (`"1-3, 5"`, default `"all"`) to + `-.` beside the file: that artboard and the art touching it. The result's `files` lists every + file written, the master file first (without a path: `files: [{name, dataBase64}]`). 3 artboards give 4 files. +- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. +- `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files; + opening the file installs them where they are missing (a warning names one that can't be used). +- `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files + (default true) with false write blank pages around the native document (smaller; other apps show empty pages). +- `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. + +File → Save As asks for these in the save options dialog (VectorCraft Options) after the save panel; Save, Save a Copy +and Save as Template reuse the options as last saved (or Use Compression). + +```json +{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}} +{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}} +```