M4.55: a journal replay reproduces File Info's dates
File Info's dates come from the clock, so documents made or saved a second apart differed: journal_replay_reproduces_document and rich_fixture_satisfies_invariants_and_undoes_to_blank failed at random. One approach for every clock-derived date: it is a command input that defaults to now, and the journal records the value used. - file.new takes created? and document.save / file.saveAs take modified? (Unix seconds, or null for none; default now), through date_param / clock_date on the one save path (save_with), so Save, Save As and the UI's save stamp alike; Save a Copy still leaves the dates. - Their journal entries always record the date used, so replaying the journal makes the same document whenever it runs. Actions recorded from the journal (Session::journal_for_action) leave those dates out, so playing an action later dates the document then. - Test fixtures date their documents fixtures::CREATED, and the MCP property test dates its local document as the headless one, so documents built at different moments compare equal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
53f42797f3
commit
851ed15104
|
|
@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<Option<i64>>,
|
||||
}
|
||||
|
||||
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<SavePlan> {
|
|||
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<String> {
|
|||
pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -380,6 +380,25 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
/// [`clock_date`]), `Some(None)` for null (no date), else `Some(Some(seconds))`.
|
||||
pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Option<i64>>> {
|
||||
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<i64>>) -> Option<i64> {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -558,7 +558,7 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<String, Value>,
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<i64> {
|
||||
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()));
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<desc>` 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"}}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue