diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 6919e182..c230b009 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "New…", ["File"], Some("Cmd+N"), - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 5009f7ce..e8e20fe8 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -74,6 +74,9 @@ pub struct SavePlan { pub name: String, /// Suggested folder: the document's own, or the Templates folder for a template. pub folder: Option, + /// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file + /// stamps; `None`: the time it is written. + pub modified: Option>, } impl SavePlan { @@ -85,12 +88,11 @@ impl SavePlan { } /// File Info's dates for a save to a file: the modified date (and the created date, when it has -/// none) become now. Not an undo step; the web build, without a clock, leaves them. -pub fn stamp_save_dates(st: &mut DocState) { - let Some(now) = vectorcraft_doc::metadata::now_unix() else { return }; +/// none) become `at`. Not an undo step. +pub fn stamp_save_dates(st: &mut DocState, at: i64) { let d = std::sync::Arc::make_mut(&mut st.doc); - d.metadata.created.get_or_insert(now); - d.metadata.modified = Some(now); + d.metadata.created.get_or_insert(at); + d.metadata.modified = Some(at); } /// The Templates folder: the `templatesFolder` preference, else `Documents/VectorCraft Templates` @@ -173,7 +175,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { SaveMode::Template => templates_folder(&s.prefs), _ => st.path.as_deref().and_then(parent_folder), }; - Ok(SavePlan { mode, path, format, options, name, folder }) + let modified = date_param(p, "modified", cmd)?; + Ok(SavePlan { mode, path, format, options, name, folder, modified }) } /// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General @@ -228,8 +231,12 @@ fn fidelity_warning(f: &Format) -> Option { pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = plan.mode.command(); let retargets = plan.retargets(); - if plan.path.is_some() && retargets { - stamp_save_dates(s.doc_mut()?); + // The web build, without a clock, leaves the dates unless given one. + if plan.path.is_some() + && retargets + && let Some(at) = clock_date(s, "modified", plan.modified) + { + stamp_save_dates(s.doc_mut()?, at); } let own = doc_to_save(s.doc()?, plan.format); // A native file records its links' paths relative to where it is written. @@ -339,7 +346,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)} → {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), 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", has_doc, |s, p| save(s, SaveMode::Save, p) ), @@ -348,7 +355,7 @@ pub(super) fn specs() -> Vec { "Save As…", ["File"], Some("Cmd+Shift+S"), - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified? (as document.save)} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", has_doc, |s, p| save(s, SaveMode::SaveAs, p) ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f0a11042..2d2a8ca1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -380,6 +380,25 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result Result>> { + match p.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(v) => v.as_i64().map(|t| Some(Some(t))).ok_or_else(|| bad(cmd, format!("{key} must be Unix seconds (an integer) or null"))), + } +} + +/// The date a command stamps: `given` (see [`date_param`]), else now (none on the web, which has +/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal +/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]). +pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option>) -> Option { + let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix); + s.note_journal(key, serde_json::json!(t)); + t +} + /// The Transparency panel's state ([`Session::transparency_info`]). pub use opacitymask::TransparencyInfo; diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 71ea7c34..ae51a7ce 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -558,7 +558,7 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { Some(t) => t.to_string(), None => s.next_untitled(), }; - d.metadata.created = vectorcraft_doc::metadata::now_unix(); + d.metadata.created = clock_date(s, "created", date_param(p, "created", C)?); let i = s.add_document(d, None); // Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both // are views of the whole app, so Default leaves them as they are. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6f95c64d..39914f03 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -197,6 +197,11 @@ impl DocState { /// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. pub const MAX_COORD: f64 = 4.0e6; +/// `(command, param)` pairs the journal records only so a replay reproduces the original run +/// (dates from the clock: `cmd::clock_date`); an action leaves them out +/// ([`Session::journal_for_action`]). +const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")]; + /// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must /// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. fn doc_sane(d: &Document, sel: &Selection) -> bool { @@ -570,8 +575,8 @@ pub struct Session { pub style_libraries: cmd::stylelib::Libraries, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, - /// Parameters the running top-level command resolved from the preferences, added to its - /// journal entry so a replay does the same ([`Session::note_journal`]). + /// Parameters the running top-level command resolved from the preferences or the clock, added + /// to its journal entry so a replay does the same ([`Session::note_journal`]). journal_note: serde_json::Map, } @@ -748,13 +753,31 @@ impl Session { } /// Record `key: value` in the running top-level command's journal entry (or its interaction's - /// preview) unless its params give `key`: a value it resolved from the preferences. + /// preview) unless its params give `key`: a value it resolved from the preferences or the clock. pub fn note_journal(&mut self, key: &str, value: Value) { if self.depth == 1 { self.journal_note.insert(key.to_string(), value); } } + /// The journal from entry `start` on, as an action records it: without the params that only + /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. + pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { + self.journal + .iter() + .skip(start) + .cloned() + .map(|(id, mut p)| { + if let Value::Object(m) = &mut p { + for (_, key) in REPLAY_ONLY.iter().filter(|(c, _)| *c == id) { + m.remove(*key); + } + } + (id, p) + }) + .collect() + } + /// `params` with the noted values added (see [`Session::note_journal`]). fn noted(&mut self, params: &Value) -> Value { let mut note = std::mem::take(&mut self.journal_note); @@ -1012,6 +1035,8 @@ mod tests_gradient; #[cfg(test)] mod tests_gradpanel; #[cfg(test)] +mod tests_journal; +#[cfg(test)] mod tests_knockout; #[cfg(test)] mod tests_labspots; diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs new file mode 100644 index 00000000..5b021f8c --- /dev/null +++ b/crates/engine/src/tests_journal.rs @@ -0,0 +1,99 @@ +//! The journal replays to the same document whenever it runs: values a command takes from the +//! clock (File ▸ New's created date, a save's modified date) are recorded in its entry, and left out +//! of recorded actions. + +use serde_json::json; + +use super::*; + +fn created(s: &Session) -> Option { + s.doc().unwrap().doc.metadata.created +} + +#[test] +fn file_new_records_its_created_date_in_the_journal() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!(id, "file.new"); + assert!(created(&s).is_some(), "a new document is dated now"); + assert_eq!(p, json!({"width": 200, "height": 100, "created": created(&s)})); +} + +#[test] +fn file_new_takes_a_given_created_date() { + let mut s = Session::new(); + s.execute("file.new", &json!({"created": 1_000_000_000})).unwrap(); + assert_eq!(created(&s), Some(1_000_000_000)); + s.execute("file.new", &json!({"created": null})).unwrap(); + assert_eq!(created(&s), None); + // Given values are journaled as given. + assert_eq!(s.journal.last().unwrap().1, json!({"created": null})); + for bad in [json!("2026-01-01"), json!(1.5), json!(true)] { + assert!(s.execute("file.new", &json!({"created": bad})).is_err(), "{bad}"); + } + assert_eq!(s.documents().len(), 2, "a bad date makes no document"); +} + +#[test] +fn replay_reproduces_the_created_date_of_an_earlier_run() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + // As if the original run happened long ago: a replay now must still land on its date. + s.journal[0].1["created"] = json!(1_234_567_890); + let mut r = Session::new(); + for (id, p) in &s.journal { + r.execute(id, p).unwrap(); + } + assert_eq!(created(&r), Some(1_234_567_890)); + assert_eq!(r.doc().unwrap().doc.node_count(), s.doc().unwrap().doc.node_count()); +} + +#[test] +fn recorded_actions_leave_out_replay_only_values() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let steps = s.journal_for_action(0); + assert_eq!(steps.len(), s.journal.len()); + // Playing the action later dates its new document then… + assert_eq!(steps[0], ("file.new".to_string(), json!({"width": 300, "height": 200}))); + // …while values resolved from the preferences stay recorded. + let scale = steps.iter().find(|(id, _)| id == "object.scale").unwrap(); + assert!(scale.1.get("strokes").is_some(), "{scale:?}"); + assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec()); +} + +#[test] +fn a_save_records_its_date_and_replays_it() { + let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned(); + let mut s = Session::new(); + s.execute("file.new", &json!({"created": null})).unwrap(); + s.execute("document.save", &json!({"path": path})).unwrap(); + let m = &s.doc().unwrap().doc.metadata; + assert!(m.modified.is_some() && m.created == m.modified, "a save dates a document without one"); + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!((id.as_str(), &p["modified"]), ("document.save", &json!(m.modified))); + // A given date is used as is; null leaves the dates alone. + s.execute("document.save", &json!({"path": path, "modified": 2_000_000_000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_000)); + s.execute("document.save", &json!({"path": path, "modified": null})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_000)); + assert!(s.execute("document.save", &json!({"path": path, "modified": "today"})).is_err()); + // Save As stamps the same way; Save a Copy leaves the document's dates (and records none). + let dir = vectorcraft_testkit::temp_dir("journal-save"); + s.execute("file.saveAs", &json!({"path": dir.join("b.vectorcraft").to_string_lossy(), "modified": 2_000_000_100})).unwrap(); + assert_eq!(s.journal.last().unwrap().1["modified"], json!(2_000_000_100)); + s.execute("file.saveCopy", &json!({"path": dir.join("c.vectorcraft").to_string_lossy()})).unwrap(); + assert!(s.journal.last().unwrap().1.get("modified").is_none()); + assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_100)); + // Replayed later, the saves stamp the recorded dates; an action stamps the time it plays. + let mut r = Session::new(); + for (id, p) in &s.journal { + r.execute(id, p).unwrap(); + } + assert_eq!(r.doc().unwrap().doc.metadata, s.doc().unwrap().doc.metadata); + assert!(s.journal_for_action(0).iter().all(|(_, p)| p.get("modified").is_none() && p.get("created").is_none())); +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index f355e5ea..41c948b4 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -212,9 +212,11 @@ proptest! { #[test] fn run_command_sequences_match_local_session(ops in arb_ops(5..40)) { let mut srv = server(); - let mut local = vectorcraft_testkit::fixtures::session_with(612.0, 792.0); - // Match the headless default document. + // Match the headless default document, dated as it is (File Info dates come from the clock). let d_remote = doc_json(&document(&mut srv)); + let mut local = vectorcraft_engine::Session::new(); + let created = &d_remote["metadata"]["created"]; + vectorcraft_testkit::fixtures::exec(&mut local, "file.new", json!({"width": 612, "height": 792, "created": created})); let d_local = doc_json(&local.doc().unwrap().doc); prop_assume!(d_remote == d_local); for op in &ops { diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index c79c39ed..23dd3d17 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -23,10 +23,14 @@ pub fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}"))) } -/// A session with one new `w`×`h` document. +/// The created date (Unix seconds) of every fixture document: File Info dates come from the clock +/// otherwise, and documents made a second apart would not compare equal. +pub const CREATED: i64 = 1_767_225_600; + +/// A session with one new `w`×`h` document, dated [`CREATED`]. pub fn session_with(w: f64, h: f64) -> Session { let mut s = Session::new(); - exec(&mut s, "file.new", json!({"width": w, "height": h})); + exec(&mut s, "file.new", json!({"width": w, "height": h, "created": CREATED})); s } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index e22fa744..b33b407b 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -97,7 +97,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if widgets::icon_button(ui, "square", "Stop Playing/Recording", false, 24.0).clicked() && let Some((set, name, start)) = app.ui.recording.take() { - let steps: Vec<(String, Value)> = app.session.journal.iter().skip(start).cloned().collect(); + let steps = app.session.journal_for_action(start); if let Some(s) = app.ui.action_sets.get_mut(set) { s.actions.push(Action { name, steps }); } diff --git a/docs/mcp.md b/docs/mcp.md index 2a20832c..5fc724b6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -920,7 +920,10 @@ ISO 8601 UTC. Pass any of the editable keys to change them in one undo step (`nu list or a comma-separated string and keeps each word once); bad values are refused. PDF export writes the title, author, description and keywords to the document info and XMP, PNG export writes them (with the copyright and creation date) as text chunks, and SVG export writes the description as `` and, with the `metadata` option, -all of it as Dublin Core. +all of it as Dublin Core. `file.new {created}` and a save to a file (`document.save` or `file.saveAs` with +`{path, modified}`) use the given date (Unix seconds, or `null` for none) instead of now. Their journal entries always +record the date they used, so replaying the journal makes the same document whenever it runs (an action recorded in +the Actions panel leaves the date out: playing it later dates the document then). ```json {"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}}