From 70e2726321040f01b03e414e6629fc2f645ff111 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:23:56 +0700 Subject: [PATCH] M4.16: Template safety: Templates folder, " template", New from Template Save as Template suggests " template.vctemplate" in the Templates folder (the new templatesFolder preference; default Documents/VectorCraft Templates, created by the desktop save panel) and never the source path. New from Template is an engine command (file.newFromTemplate {path} or {name, dataBase64}) that opens any readable file as a new Untitled-N document; in the app, bytes from an agent go straight to the engine, otherwise the open dialog starts in the Templates folder with template types first. .ait and template files keep opening untitled. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 8 ++++- crates/engine/src/cmd/fileio/load.rs | 20 +++++++++-- crates/engine/src/cmd/fileio/mod.rs | 8 ++--- crates/engine/src/cmd/fileio/save.rs | 39 +++++++++++++++++----- crates/engine/src/cmd/prefscmds.rs | 2 ++ crates/engine/src/lib.rs | 4 +++ crates/engine/src/tests_save.rs | 38 +++++++++++++++++++-- crates/ui-egui/src/dialogs/save_options.rs | 3 +- crates/ui-egui/src/io.rs | 22 ++++++++++++ crates/ui-egui/src/menus.rs | 7 ++-- crates/ui-egui/src/tests_save.rs | 11 ++++++ docs/mcp.md | 3 +- 12 files changed, 141 insertions(+), 24 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index d512d164..7141347c 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -119,7 +119,13 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), - pick_save: Some(Box::new(|pick: &FilePick| file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string()))), + pick_save: Some(Box::new(|pick: &FilePick| { + // The Templates folder may not exist yet. + if let Some(folder) = &pick.folder { + let _ = std::fs::create_dir_all(folder); + } + file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string()) + })), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ca9ed054..f4947642 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -151,11 +151,22 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let Loaded { mut doc, format, warnings, restored } = load_with(name, bytes, &opts)?; + open_loaded(s, load_with(name, bytes, &opts)?, path, &opts, false) +} + +/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for +/// from there) as a new untitled document. +pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result { + open_loaded(s, load(name, bytes)?, path.map(str::to_string), &LoadOptions::default(), true) +} + +/// Make a loaded file the new active document; `opts` are the options it was read with. +fn open_loaded(s: &mut Session, loaded: Loaded, path: Option, opts: &LoadOptions, as_template: bool) -> Result { + let Loaded { mut doc, format, warnings, restored } = loaded; let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically"); // A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled // document. - let template = doc.template || matches!(format.id, "ait" | "template"); + let template = as_template || doc.template || matches!(format.id, "ait" | "template"); if template { doc.template = false; doc.title = s.next_untitled(); @@ -200,6 +211,11 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result { open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p) } +pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result { + let src = source(p, "file.newFromTemplate")?; + open_template(s, src.name, &src.bytes, src.path) +} + /// Decode an image's header (and, for formats stored as PNG, its pixels). pub fn raster_image(bytes: &[u8]) -> Result { let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 8f14dbe9..37d05433 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -5,8 +5,8 @@ //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`. -//! - `save`: `document.save`, Save As / a Copy / as Template, Revert and the per-format options -//! (`file.formatOptions`); [`save_with`] is the one save path of every frontend. +//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the +//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. //! @@ -30,9 +30,9 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc pub(crate) use encode::{anti_alias, background, with_single_artboard}; use load::err; pub(crate) use load::source; -pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image}; +pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; pub use pdfimport::{LoadOptions, page_document}; -pub use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates}; +pub use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates, templates_folder}; pub use svg::options_map as svg_options; use super::*; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 60183051..57a9519f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1,5 +1,5 @@ -//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `file.revert` -//! and `file.formatOptions`. +//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, +//! `file.newFromTemplate`, `file.revert` and `file.formatOptions`. //! //! Every frontend saves the same way: [`save_plan`] resolves where and how (path, format, options, //! a suggested name and folder when there is no path), then [`save_with`] encodes, writes through @@ -11,7 +11,7 @@ use vectorcraft_doc::Document; use super::super::*; use super::{Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, write_encoded, write_file}; -use crate::DocState; +use crate::{DocState, Prefs}; /// The formats Save As offers, in menu order (append-only). The other writable formats are exports: /// they never become the document's own file. @@ -68,9 +68,9 @@ pub struct SavePlan { pub format: &'static Format, /// The format's options (only those its encoder reads). pub options: Map, - /// Suggested file name: `.` or ` copy.`. + /// Suggested file name: `.`, ` copy.` or ` template.vctemplate`. pub name: String, - /// Suggested folder: the document's own. + /// Suggested folder: the document's own, or the Templates folder for a template. pub folder: Option, } @@ -91,6 +91,16 @@ pub fn stamp_save_dates(st: &mut DocState) { d.metadata.modified = Some(now); } +/// The Templates folder: the `templatesFolder` preference, else `Documents/VectorCraft Templates` +/// in the user's home (none where there is no home folder, as on the web). +pub fn templates_folder(prefs: &Prefs) -> Option { + if !prefs.templates_folder.is_empty() { + return Some(prefs.templates_folder.clone()); + } + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty())?; + Some(std::path::Path::new(&home).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string()) +} + /// The folder of a path (`None` for a bare file name). fn parent_folder(path: &str) -> Option { std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) @@ -154,9 +164,13 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { let ext = format.extensions[0]; let name = match mode { SaveMode::Copy => format!("{stem} copy.{ext}"), - SaveMode::Save | SaveMode::SaveAs | SaveMode::Template => format!("{stem}.{ext}"), + SaveMode::Template => format!("{stem} template.{ext}"), + SaveMode::Save | SaveMode::SaveAs => format!("{stem}.{ext}"), + }; + let folder = match mode { + SaveMode::Template => templates_folder(&s.prefs), + _ => st.path.as_deref().and_then(parent_folder), }; - let folder = st.path.as_deref().and_then(parent_folder); Ok(SavePlan { mode, path, format, options, name, folder }) } @@ -338,10 +352,19 @@ pub(super) fn specs() -> Vec { "Save as Template…", ["File"], None, - "{path?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \".vctemplate\", folder?, warnings}", + "{path?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \" template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}", has_doc, |s, p| save(s, SaveMode::Template, p) ), + cmd!( + "file.newFromTemplate", + "New from Template…", + ["File"], + Some("Cmd+Shift+N"), + "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}", + always, + super::load::new_from_template + ), cmd!( "file.revert", "Revert", diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 9ee95dde..aa5242b0 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -315,6 +315,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("japaneseCropMarks", "General", "Options", "Use Japanese Crop Marks", bool), // Appearance panel menu p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool), + // File Handling (continued) + p!("templatesFolder", "File Handling", "Files", "Templates Folder", text), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 90071e35..50599b78 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -372,6 +372,9 @@ pub struct Prefs { /// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub pdf_presets: Vec, + // File Handling (continued) + /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). + pub templates_folder: String, } impl Default for Prefs { @@ -498,6 +501,7 @@ impl Default for Prefs { new_doc_presets: vec![], recent_new_docs: vec![], pdf_presets: vec![], + templates_folder: String::new(), } } } diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs index 7bb8e688..af9445e7 100644 --- a/crates/engine/src/tests_save.rs +++ b/crates/engine/src/tests_save.rs @@ -1,5 +1,5 @@ -//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template and -//! Revert. +//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert +//! and templates. use std::path::PathBuf; @@ -161,6 +161,40 @@ fn revert_reloads_the_saved_file_in_the_same_tab() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn templates_suggest_a_name_in_the_templates_folder_and_open_untitled() { + let d = dir("tpl"); + let mut s = session(); + let source = path(&d, "flyer.vectorcraft"); + s.execute("document.save", &json!({"path": source})).unwrap(); + s.prefs.templates_folder = path(&d, "Templates"); + let r = s.execute("file.saveAsTemplate", &json!({})).unwrap(); + assert_eq!(r["name"], "flyer template.vctemplate"); + assert_eq!(r["folder"].as_str(), Some(s.prefs.templates_folder.as_str())); + assert_ne!(r["name"].as_str(), std::path::Path::new(&source).file_name().and_then(|n| n.to_str()), "never the source file"); + // Writing it leaves the document alone; opening it gives an untitled document. + rect(&mut s); + let tpl = path(&d, "flyer template.vctemplate"); + s.execute("file.saveAsTemplate", &json!({"path": tpl})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(source.as_str())); + assert!(s.doc().unwrap().is_dirty()); + let r = s.execute("document.open", &json!({"path": tpl})).unwrap(); + assert_eq!(r["format"], "template"); + assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); + assert_eq!(s.doc().unwrap().path, None); + assert!(!s.doc().unwrap().doc.template); + // New from Template opens any file untitled. + let r = s.execute("file.newFromTemplate", &json!({"path": source})).unwrap(); + assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); + assert_eq!(s.doc().unwrap().path, None); + // With no preference the folder is under the user's home (where there is one). + s.prefs.templates_folder.clear(); + if let Some(f) = crate::cmd::fileio::templates_folder(&s.prefs) { + assert!(f.ends_with("VectorCraft Templates"), "{f}"); + } + let _ = std::fs::remove_dir_all(d); +} + #[test] fn save_plans_suggest_names_and_folders() { let mut s = session(); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 5307ed73..e8f4b053 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -269,6 +269,7 @@ mod tests { #[test] fn save_a_copy_and_template_suggest_their_names() { let (mut app, written, picks) = desktop("copy.vectorcraft"); + app.session.prefs.templates_folder = "/templates".into(); app.run("file.saveCopy", json!({})).unwrap(); assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); assert!(app.ui.dialog.is_none(), "the native format has no options"); @@ -276,7 +277,7 @@ mod tests { assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); app.run("file.saveAsTemplate", json!({})).unwrap(); let pick = &picks.borrow()[1]; - assert_eq!((pick.name.as_str(), pick.folder.as_deref()), ("Untitled-1.vctemplate", None)); + assert_eq!((pick.name.as_str(), pick.folder.as_deref()), ("Untitled-1 template.vctemplate", Some("/templates"))); assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..])]); assert_eq!(written.borrow()[1].0, "copy.vectorcraft", "the picked name is kept"); } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index bf000a53..e0c032b2 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -9,6 +9,9 @@ use crate::dialogs::svg_options; use crate::state::Dialog; use crate::{FilePick, Services, VectorcraftApp, dialogs}; +/// Template extensions New from Template's open dialog lists first. +const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; + /// Open bytes of any readable format as a new document (templates open untitled); swatch and /// graphic style library files open in the library panel and flattener and PDF presets files are /// imported. @@ -77,6 +80,25 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { Ok(()) } +/// File → New from Template…: a template (or any readable file) as a new untitled document. Without +/// a path the open dialog starts in the Templates folder. On the web the browser's file picker +/// opens it (`.vctemplate`, `.ait` and template files open untitled there too). +pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Result { + let path = match path { + Some(p) => p, + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => { + let filters = std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters()).collect(); + pick_open(app, &FilePick { folder: fileio::templates_folder(&app.session.prefs), filters, ..Default::default() })? + } + }; + let bytes = read(app, &path)?; + let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?; + app.sync_views(); + crate::dialogs::missing_links::after_open(app, &r); + Ok(r) +} + /// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { if let Some(dl) = services.download.as_mut() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0d72f78a..b0069464 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -56,7 +56,6 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+S", "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved) the Save As panel asks first", ), - ("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"), ( "file.place", "Place…", @@ -438,10 +437,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } - "file.newFromTemplate" => match s("path") { - Some(path) => io::open_path(app, &path).map(|_| Value::Null), - None => io::open_dialog(app).map(|_| Value::Null), - }, + // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. + "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), id if id.starts_with("file.openRecent") => { let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0); diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs index dc92aa3a..976df367 100644 --- a/crates/ui-egui/src/tests_save.rs +++ b/crates/ui-egui/src/tests_save.rs @@ -74,3 +74,14 @@ fn revert_asks_then_reloads_in_the_same_tab_keeping_every_zoom() { assert!(app.run("file.revert", json!({})).is_err() && app.ui.dialog.is_none()); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn new_from_template_takes_bytes_from_agents() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let bytes = app.session.execute("document.serialize", &json!({"format": "template"})).unwrap(); + let r = app.run("file.newFromTemplate", json!({"name": "flyer.vctemplate", "dataBase64": bytes["dataBase64"]})).unwrap(); + assert!(r["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{r}"); + assert_eq!((app.session.documents().len(), app.views.len()), (2, 2)); + assert_eq!(app.session.active().unwrap().path, None); +} diff --git a/docs/mcp.md b/docs/mcp.md index 34bfd898..bdfcd87a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -141,7 +141,8 @@ markup around it: if another app changed the SVG since, `document.open` reads it Saving beyond `save_file` goes through `run_command`: `file.saveAs {path?, format?, options?, svg?}` (the document takes on the new file and format), `file.saveCopy` (the document keeps its path, title and modified state), -`file.saveAsTemplate` (a `.vctemplate` that opens untitled), `file.revert` and `file.formatOptions {format?}` (a format's options +`file.saveAsTemplate` (a `.vctemplate` that opens untitled; the suggested name and the Templates folder come back +without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF remembers its options, so the next save reuses them. A PDF opened with `pages`, `cropTo` or `password` is only part of the file, so Save asks for a new name instead of writing it back.