diff --git a/ROADMAP.md b/ROADMAP.md index 6ee10d54a..9cb35f9e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -142,6 +142,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-19 | Personal templates (#2852): File › New lists the `.dotx` and `.dotm` files in your templates folder under Personal (a subfolder per category; the search box covers them), and one opens as a new untitled document attached to it, as in Word. Save As › Save as Word Template starts in that folder, made on first use in the settings folder; File › Options › Advanced › File locations › User templates points it elsewhere. Agents list them with `file.listTemplates`. | | 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | @@ -792,3 +793,4 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) | | 2026-10-15 | trivial | Progress log: ruler numbers by zoom, rulers follow the caret's page (#1878) | | 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) | +| 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) | diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 7196f96de..002354ce2 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -93,8 +93,12 @@ fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking { match req.clone() { FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }), FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }), - FileDialogRequest::Save { name } => { + FileDialogRequest::Save { name, dir } => { let mut dialog = dialog.set_file_name(name.clone()); + // Save as Word Template starts in the user templates folder (#2852). + if let Some(dir) = dir.filter(|d| std::path::Path::new(d).is_dir()) { + dialog = dialog.set_directory(dir); + } for (label, exts) in save_filters(&name) { dialog = dialog.add_filter(label, &exts); } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index db5b2488a..f605d3a98 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -244,6 +244,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { print_native: Some(print::submit()), clipboard_picture: Some(Box::new(clipboard::picture)), make_default_app: default_apps::hook(), + // Personal templates live in `Templates` in the settings folder (#2852), made on first save. + templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")), animations_enabled: Some(animations::hook(ctx)), clipboard_text: Some(Box::new(clipboard::text)), clipboard_html: Some(Box::new(clipboard::html)), diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 2041d210c..5bdd74416 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -52,6 +52,22 @@ pub fn read_xml(bytes: &[u8]) -> Result { } } +/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376 +/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's +/// directory and at most `max_part` bytes of that part are read: for listing templates by name +/// without reading the whole package. +pub fn package_title(package: R, max_part: u64) -> Option { + use std::io::Read; + let mut zip = zip::ZipArchive::new(package).ok()?; + let part = zip.by_name("docProps/core.xml").ok()?; + let mut xml = Vec::new(); + part.take(max_part).read_to_end(&mut xml).ok()?; + let root = xml::parse(&xml).ok()?; + let title = root.child("dc:title")?.text(); + let title = title.trim(); + (!title.is_empty()).then(|| title.to_string()) +} + /// What [`DocxError::PasswordRequired`] says. pub const PASSWORD_REQUIRED: &str = "this document is protected with a password"; /// What [`DocxError::WrongPassword`] says. diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 8e7897486..f511ece41 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -30,6 +30,7 @@ File|Backstage|Version History|file.versions File|Backstage|Recover Unsaved|file.recover File|Backstage|New from Template|file.newFromTemplate File|Backstage|Save as Template|file.saveTemplate +File|Backstage|Personal Templates|file.listTemplates Quick Access|Toolbar|Undo|edit.undo Quick Access|Toolbar|Undo List|edit.undoTo Quick Access|Toolbar|Redo|edit.redo diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5e3d306ad..f354a955d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -47,6 +47,7 @@ pub mod table_style; pub mod templates; pub mod text; pub mod tools; +pub mod user_templates; pub mod view; pub mod wordart; @@ -99,6 +100,7 @@ pub fn registry() -> Registry { v.extend(connectors::specs()); v.extend(tools::specs()); v.extend(templates::specs()); + v.extend(user_templates::specs()); v.extend(protect::specs()); v.extend(speech::specs()); v.extend(controls::specs()); diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index a829f4c07..a5ecd256f 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -11,7 +11,7 @@ pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "languag /// Longest initials kept, in characters. pub const MAX_INITIALS_CHARS: usize = 9; -/// Longest dictionaries folder path taken, in characters. +/// Longest folder path (dictionaries, user templates) taken, in characters. const MAX_FOLDER_CHARS: usize = 4096; /// Longest word the custom dictionary takes, in characters. pub const MAX_WORD_CHARS: usize = 64; @@ -22,7 +22,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("file.options", "Options", "File", options) .params( - r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, + r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, ) .pure(), CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| { @@ -117,9 +117,15 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { }; let folder = match v.get("dictionariesFolder") { None | Some(Value::Null) => None, - Some(Value::String(f)) => Some(dictionaries_folder(f)?), + Some(Value::String(f)) => Some(folder_setting("dictionariesFolder", f, false)?), Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())), }; + let templates_folder = match v.get("userTemplatesFolder") { + None | Some(Value::Null) => None, + // Never a folder on another machine (#2852): listing it would make Windows authenticate there. + Some(Value::String(f)) => Some(folder_setting("userTemplatesFolder", f, true)?), + Some(_) => return Err(CmdError::Params("`userTemplatesFolder` must be a folder path, or auto".into())), + }; let mut flag_values = Vec::new(); for (k, f) in flags() { if let Some(b) = bool_param(v, k)? { @@ -147,6 +153,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { || units.is_some() || region.is_some() || folder.is_some() + || templates_folder.is_some() || !flag_values.is_empty() || !mark_values.is_empty() || check_spelling.is_some() @@ -168,6 +175,9 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { use_dictionaries_folder(f.as_deref()); s.prefs.dictionaries_folder = f; } + if let Some(f) = templates_folder { + s.prefs.user_templates_folder = f; + } for (f, b) in flag_values { *f(&mut s.prefs) = b; } @@ -221,6 +231,7 @@ pub fn current(s: &Session) -> Value { "ignoreInternet": pr.ignore_internet, "dictionary": wordcraft_proof::user_dictionary(), "dictionariesFolder": pr.dictionaries_folder, + "userTemplatesFolder": pr.user_templates_folder, "dictionaries": wordcraft_proof::installed_dictionaries() .into_iter() .map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())})) @@ -228,23 +239,28 @@ pub fn current(s: &Session) -> Value { }) } -/// The `dictionariesFolder` setting: `auto` (or nothing) forgets it; otherwise an existing -/// folder, given as an absolute path or one starting with `~/`. -fn dictionaries_folder(f: &str) -> Result, CmdError> { +/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets +/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With +/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking +/// that it exists would make Windows authenticate to that host. +fn folder_setting(key: &str, f: &str, local_only: bool) -> Result, CmdError> { let f = f.trim(); if f.is_empty() || f.eq_ignore_ascii_case("auto") { return Ok(None); } if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) { - return Err(CmdError::Params("`dictionariesFolder` isn't a folder path".into())); + return Err(CmdError::Params(format!("`{key}` isn't a folder path"))); } let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from); let path = match (f.strip_prefix("~/"), home) { (Some(rest), Some(h)) => h.join(rest), _ => std::path::PathBuf::from(f), }; + if local_only && (super::templates::is_network_path(f) || super::templates::is_network_path(&path.to_string_lossy())) { + return Err(CmdError::Params(format!("`{key}` can't be a network folder"))); + } if !path.is_absolute() { - return Err(CmdError::Params(format!("`dictionariesFolder`: {f:?} isn't a full path"))); + return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path"))); } if !path.is_dir() { return Err(CmdError::Failed(format!("no folder at {}", path.display()))); diff --git a/crates/engine/src/cmd/templates.rs b/crates/engine/src/cmd/templates.rs index 8cbde64b8..60d4044a0 100644 --- a/crates/engine/src/cmd/templates.rs +++ b/crates/engine/src/cmd/templates.rs @@ -240,7 +240,7 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option { Some(p) } -fn is_network_path(p: &str) -> bool { +pub(crate) fn is_network_path(p: &str) -> bool { let b = p.as_bytes(); matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/'))) } diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index ac8393f53..f19b8d444 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -489,17 +489,20 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| { let path = p::req_str(v, "path")?; - let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; + let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; + // As in Word, the new document is attached to the template it came from (#2852). + doc.settings.attached_template = Some(path.to_string()); s.set_document(doc); s.path = None; s.dirty = true; sel_result(s) }) - .params(r#"{"path": string}"#) + .params(r#"{"path": string} → a new, untitled document from the template, attached to it"#) .pure(), CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| { let path = p::req_str(v, "path")?; - let path = if path.ends_with(".dotx") { path.to_string() } else { format!("{path}.dotx") }; + // A .dotm keeps its name (#2852); anything else becomes a .dotx. + let path = if super::user_templates::is_template_name(path) { path.to_string() } else { format!("{path}.dotx") }; crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?; Ok(json!({"path": path})) }) diff --git a/crates/engine/src/cmd/user_templates.rs b/crates/engine/src/cmd/user_templates.rs new file mode 100644 index 000000000..db944578e --- /dev/null +++ b/crates/engine/src/cmd/user_templates.rs @@ -0,0 +1,393 @@ +//! Personal templates (#2852): the `.dotx`/`.dotm` files in the user templates folder, which +//! File › New lists next to the built-in templates. +//! +//! The folder is the user's own (File › Options › Advanced › File locations, else the app's +//! `Templates` folder, which the front end passes as `dir`). As in Word, the templates at its top +//! level are listed, and each folder directly inside it is a category of the templates it holds; +//! deeper folders are not looked into. A template is named by its file name, as in Word's +//! gallery; its title (the core properties' `dc:title`), when it has one, comes along for search +//! (a template saved from a document often keeps that document's title, so it can't be the name). +//! +//! Everything read here comes from the disk, so it is bounded: the number of entries looked at, +//! of categories and of templates listed, the size of a template whose title is read and of the +//! part it is read from. A file that can't be read is listed by its name or skipped, never an +//! error. A network folder (`\\host\share`) is refused, as attached templates are +//! ([`super::templates`]), and the automation read root applies to the folder and each file. + +use serde_json::{Value, json}; + +use crate::{CmdError, CmdResult, CommandSpec, Session}; + +/// Most templates one listing returns. +pub const MAX_TEMPLATES: usize = 500; +/// Most category folders looked into. +pub const MAX_CATEGORIES: usize = 64; +/// Most directory entries looked at in one folder. +const MAX_ENTRIES: usize = 10_000; +/// Longest folder path taken, in bytes. +const MAX_DIR: usize = 32 * 1024; +/// Largest template whose title is read (bigger ones are listed by file name). +#[cfg(not(target_arch = "wasm32"))] +const MAX_TITLE_FILE: u64 = 64 << 20; +/// Most bytes of the core properties part read for the title. +#[cfg(not(target_arch = "wasm32"))] +const MAX_CORE_XML: u64 = 64 << 10; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("file.listTemplates", "Personal Templates", "File › New", list) + .params(r#"{"dir"?: string (the user templates folder; default: the one saved in File › Options)} → {"dir", "templates": [{"label" (the file name without its extension), "title": string|null (dc:title), "path", "category" ("" at the top level, else the subfolder's name), "modified": ISO-8601|null}] (.dotx and .dotm, at most 500, by category then label), "truncated": bool}; a network folder is refused"#) + .pure(), + ] +} + +/// One template in the folder. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PersonalTemplate { + /// Its file name without the extension. + pub label: String, + /// The title in its core properties, if any. + pub title: Option, + pub path: String, + /// The subfolder it is in; empty at the top level. + pub category: String, + /// When the file last changed (ISO-8601, UTC), if the system says. + pub modified: Option, +} + +/// What [`list_templates`] found. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Listing { + pub templates: Vec, + /// Some were left out (a cap was reached). + pub truncated: bool, +} + +fn list(s: &mut Session, v: &Value) -> CmdResult { + let dir = match v.get("dir") { + None | Some(Value::Null) => { + s.prefs.user_templates_folder.clone().ok_or_else(|| CmdError::Params("give `dir`: no user templates folder is saved".into()))? + } + Some(Value::String(d)) => d.clone(), + Some(_) => return Err(CmdError::Params("`dir` must be a folder path".into())), + }; + let found = list_templates(&dir)?; + Ok(json!({ + "dir": dir, + "templates": found.templates.iter().map(|t| json!({"label": t.label, "title": t.title, "path": t.path, "category": t.category, "modified": t.modified})).collect::>(), + "truncated": found.truncated, + })) +} + +/// Check that `dir` may be listed: a full local path, not too long, not on another machine. +pub fn check_dir(dir: &str) -> Result<(), CmdError> { + let d = dir.trim(); + if d.is_empty() { + return Err(CmdError::Params("`dir` is empty".into())); + } + if d.len() > MAX_DIR || d.chars().any(char::is_control) { + return Err(CmdError::Params("`dir` isn't a folder path".into())); + } + // A folder on another machine: listing it would make Windows authenticate to that host. + if super::templates::is_network_path(d) { + return Err(CmdError::Params("templates aren't listed from a network folder".into())); + } + if !std::path::Path::new(d).is_absolute() { + return Err(CmdError::Params(format!("`dir`: {d:?} isn't a full path"))); + } + Ok(()) +} + +/// The personal templates in `dir` (see the module docs). +#[cfg(not(target_arch = "wasm32"))] +pub fn list_templates(dir: &str) -> Result { + use std::path::Path; + check_dir(dir)?; + let dir = dir.trim(); + crate::file_access::check_read(dir).map_err(CmdError::Failed)?; + let root = Path::new(dir); + if !root.is_dir() { + return Err(CmdError::Failed(format!("no folder at {dir}"))); + } + let mut out = Listing::default(); + let (files, folders, cut) = entries(root); + out.truncated |= cut; + let mut found: Vec<(String, crate::io::DirEntry)> = files.into_iter().map(|f| (String::new(), f)).collect(); + let mut folders = folders; + folders.sort(); + if folders.len() > MAX_CATEGORIES { + folders.truncate(MAX_CATEGORIES); + out.truncated = true; + } + for folder in folders { + let Some(category) = folder.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue }; + if crate::file_access::check_read(&folder.to_string_lossy()).is_err() { + continue; + } + let (files, _, cut) = entries(&folder); + out.truncated |= cut; + found.extend(files.into_iter().map(|f| (category.clone(), f))); + } + // The cap applies to what is listed; titles are read only for those. + found.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.1.path.cmp(&b.1.path))); + if found.len() > MAX_TEMPLATES { + found.truncate(MAX_TEMPLATES); + out.truncated = true; + } + for (category, e) in found { + let title = title_of(&e.path); + let modified = e.modified.map(super::iso_from_unix_secs); + out.templates.push(PersonalTemplate { label: label_of(&e.name), title, path: e.path.to_string_lossy().into_owned(), category, modified }); + } + // Two templates of one category that would show the same name (`Memo.dotx`, `Memo.dotm`) + // show their file names instead, so they can be told apart. + let key = |t: &PersonalTemplate| (t.category.to_lowercase(), t.label.to_lowercase()); + let mut seen = std::collections::HashMap::<(String, String), usize>::new(); + for t in &out.templates { + *seen.entry(key(t)).or_default() += 1; + } + for t in &mut out.templates { + if seen.get(&key(t)).is_some_and(|n| *n > 1) + && let Some(name) = std::path::Path::new(&t.path).file_name() + { + t.label = name.to_string_lossy().into_owned(); + } + } + // The top level first, then each category; by label within them. + out.templates.sort_by_cached_key(|t| (!t.category.is_empty(), t.category.to_lowercase(), t.label.to_lowercase(), t.path.clone())); + Ok(out) +} + +#[cfg(target_arch = "wasm32")] +pub fn list_templates(dir: &str) -> Result { + check_dir(dir)?; + Err(CmdError::Failed("templates folders can't be listed on the web".into())) +} + +/// The template files and the folders directly in `dir` (hidden ones left out), and whether the +/// entry cap cut the listing short. An unreadable folder has none. +#[cfg(not(target_arch = "wasm32"))] +fn entries(dir: &std::path::Path) -> (Vec, Vec, bool) { + let Ok((list, cut)) = crate::io::list_dir(dir, MAX_ENTRIES) else { return (Vec::new(), Vec::new(), false) }; + let (mut files, mut folders) = (Vec::new(), Vec::new()); + for e in list { + if e.name.starts_with('.') || e.name.starts_with("~$") { + continue; + } + if e.dir { + folders.push(e.path); + } else if is_template_name(&e.name) { + files.push(e); + } + } + (files, folders, cut) +} + +/// The name a template shows: its file name without the template extension, every one of them +/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if +/// nothing would be left. +fn label_of(name: &str) -> String { + let mut label = name; + while is_template_name(label) { + let Some(stem) = label.get(..label.len().saturating_sub(5)) else { break }; + label = stem; + } + let label = label.trim_end(); + if label.is_empty() { name.to_string() } else { label.to_string() } +} + +/// A Word template by its name: `.dotx` or `.dotm`, in any case. +pub fn is_template_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + (lower.ends_with(".dotx") || lower.ends_with(".dotm")) && lower.len() > 5 +} + +/// The template's title from its core properties, read from the package's directory and at most +/// [`MAX_CORE_XML`] bytes of the part; `None` for anything else. +#[cfg(not(target_arch = "wasm32"))] +fn title_of(path: &std::path::Path) -> Option { + let title = crate::io::package_title(path, MAX_TITLE_FILE, MAX_CORE_XML)?; + // One line of a sensible length for a gallery caption. + let title: String = title.chars().map(|c| if c.is_control() { ' ' } else { c }).take(200).collect(); + let title = title.trim(); + (!title.is_empty()).then(|| title.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn scratch(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("wc-personal-templates-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// A real template written by the engine, with `title` as its core title (empty: none). + fn template(path: &std::path::Path, text: &str, title: &str) { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": text})).unwrap(); + s.doc.core.title = title.into(); + crate::io::save_path(path, &s.doc).unwrap(); + } + + fn labels(r: &Value) -> Vec<(String, String)> { + r["templates"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["category"].as_str().unwrap().to_string(), t["label"].as_str().unwrap().to_string())) + .collect() + } + + #[test] + fn lists_dotx_and_dotm_with_subfolders_as_categories() { + let d = scratch("list"); + template(&d.join("Weekly report.dotx"), "Report body", ""); + template(&d.join("letterhead.dotx"), "Letter body", "Company letterhead"); + template(&d.join("Macros.dotm"), "Macro body", ""); + template(&d.join("Not a template.docx"), "Doc body", ""); + std::fs::write(d.join("notes.txt"), "x").unwrap(); + std::fs::write(d.join("Broken.dotx"), b"this is no zip").unwrap(); + std::fs::write(d.join(".hidden.dotx"), b"x").unwrap(); + std::fs::create_dir_all(d.join("Legal/Deeper")).unwrap(); + template(&d.join("Legal/NDA.DOTX"), "NDA body", ""); + template(&d.join("Legal/Deeper/Too deep.dotx"), "x", ""); + std::fs::create_dir_all(d.join("Empty")).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + let r = s.run("file.listTemplates", &json!({"dir": d.to_string_lossy()})).unwrap(); + assert_eq!( + labels(&r), + [ + ("".into(), "Broken".into()), + ("".into(), "letterhead".into()), + ("".into(), "Macros".into()), + ("".into(), "Weekly report".into()), + ("Legal".into(), "NDA".into()), + ], + "{r}" + ); + assert_eq!(r["truncated"], json!(false)); + let first = &r["templates"][1]; + assert_eq!(first["title"], json!("Company letterhead")); + assert_eq!(r["templates"][0]["title"], json!(null), "a broken file has no title"); + assert!(first["path"].as_str().unwrap().ends_with("letterhead.dotx")); + assert!(first["modified"].as_str().is_some_and(|m| m.ends_with('Z'))); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn refuses_network_relative_and_missing_folders() { + let mut s = Session::new(wordcraft_doc::Document::new()); + for dir in [r"\\host\share\Templates", "//host/share/Templates", "relative/Templates", "", " "] { + assert!(matches!(s.run("file.listTemplates", &json!({"dir": dir})), Err(CmdError::Params(_))), "{dir:?}"); + } + let missing = std::env::temp_dir().join(format!("wc-no-such-templates-{}", std::process::id())); + assert!(s.run("file.listTemplates", &json!({"dir": missing.to_string_lossy()})).is_err()); + // Without `dir` the saved folder is used; none saved is an error. + assert!(matches!(s.run("file.listTemplates", &json!({})), Err(CmdError::Params(_)))); + for junk in [json!(null), json!({"dir": 5}), json!({"dir": []}), json!({"dir": "x".repeat(40_000)}), json!({"dir": "/a\u{0}b"})] { + let _ = s.run("file.listTemplates", &junk); + } + } + + #[test] + fn the_saved_folder_is_listed_without_dir() { + let d = scratch("saved"); + template(&d.join("Saved.dotx"), "x", ""); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap(); + let r = s.run("file.listTemplates", &json!({})).unwrap(); + assert_eq!(labels(&r), [("".to_string(), "Saved".to_string())]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_listing_is_capped() { + let d = scratch("cap"); + for i in 0..MAX_TEMPLATES + 5 { + std::fs::write(d.join(format!("t{i:04}.dotx")), b"").unwrap(); + } + let found = list_templates(&d.to_string_lossy()).unwrap(); + assert_eq!(found.templates.len(), MAX_TEMPLATES); + assert!(found.truncated); + assert_eq!(found.templates.first().map(|t| t.label.as_str()), Some("t0000")); + let _ = std::fs::remove_dir_all(&d); + } + + /// A document made from a personal template is untitled, unsaved and attached to the + /// template, as in Word. + #[test] + fn new_from_template_is_untitled_and_attached() { + let d = scratch("new"); + let path = d.join("Memo.dotx"); + template(&path, "Memo text", ""); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.path = Some(d.join("Earlier.docx")); + s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.path, None); + assert!(s.dirty); + assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Memo text")); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_templates_folder_option_is_saved_and_checked() { + let d = scratch("option"); + let mut s = Session::new(wordcraft_doc::Document::new()); + for bad in [json!(r"\\host\share"), json!("//host/share"), json!("relative/x"), json!(7)] { + assert!(s.run("file.options", &json!({"userTemplatesFolder": bad})).is_err(), "{bad}"); + } + assert!(s.run("file.options", &json!({"userTemplatesFolder": d.join("missing").to_string_lossy()})).is_err()); + assert_eq!(s.prefs.user_templates_folder, None, "a refused folder changes nothing"); + let r = s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap(); + assert_eq!(r["userTemplatesFolder"], json!(d.to_string_lossy())); + assert_eq!(serde_json::to_value(&s.prefs).unwrap()["userTemplatesFolder"], json!(d.to_string_lossy())); + s.run("file.options", &json!({"userTemplatesFolder": "auto"})).unwrap(); + assert_eq!(s.prefs.user_templates_folder, None); + let _ = std::fs::remove_dir_all(&d); + } + + /// .dotx and .dotm templates show their names the same way, without the extension; two that + /// would look alike in one category show their file names. + #[test] + fn labels_drop_the_extension_alike_and_tell_twins_apart() { + let d = scratch("labels"); + template(&d.join("Service contract.dotm"), "a", ""); + template(&d.join("Old contract.dotm.dotx"), "b", ""); + template(&d.join("Memo.dotx"), "c", ""); + template(&d.join("Memo.DOTM"), "d", ""); + std::fs::create_dir_all(d.join("Other")).unwrap(); + template(&d.join("Other/Memo.dotx"), "e", ""); + let found = list_templates(&d.to_string_lossy()).unwrap(); + let got: Vec<(&str, &str)> = found.templates.iter().map(|t| (t.category.as_str(), t.label.as_str())).collect(); + assert_eq!(got, [("", "Memo.DOTM"), ("", "Memo.dotx"), ("", "Old contract"), ("", "Service contract"), ("Other", "Memo")]); + assert_eq!(label_of(".dotx.dotm"), ".dotx", "never empty"); + assert_eq!(label_of(" .dotx"), " .dotx", "nothing left: the whole name"); + assert_eq!(label_of("é.dotx"), "é"); + let _ = std::fs::remove_dir_all(&d); + } + + /// Save as Template keeps a .dotm (any case) and makes anything else a .dotx. + #[test] + fn save_template_keeps_a_template_extension() { + let d = scratch("save"); + let mut s = Session::new(wordcraft_doc::Document::new()); + for (asked, saved) in [("A.dotm", "A.dotm"), ("B.DOTX", "B.DOTX"), ("C", "C.dotx"), ("D.docx", "D.docx.dotx")] { + let r = s.run("file.saveTemplate", &json!({"path": d.join(asked).to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(d.join(saved).to_string_lossy())); + assert!(d.join(saved).is_file(), "{saved}"); + } + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn template_names() { + assert!(is_template_name("a.dotx") && is_template_name("B.DOTM")); + assert!(!is_template_name(".dotx") && !is_template_name("a.docx") && !is_template_name("a.dot")); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5c1029308..ff7378728 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -561,6 +561,59 @@ pub fn read_file_head(path: &str, max: u64) -> Result, String> { Ok(head) } +/// An entry of [`list_dir`]: a file or folder (links followed). +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +pub struct DirEntry { + pub name: String, + pub path: std::path::PathBuf, + pub dir: bool, + /// The file's size in bytes (0 for a folder). + pub len: u64, + /// When it last changed, in seconds since the Unix epoch, if the system says. + pub modified: Option, +} + +/// The files and folders in the folder at `path`, inside the automation read root (entries +/// leading outside it, and ones that can't be read, are left out); at most `max` entries are +/// looked at, and `true` says the listing was cut short there. +#[cfg(not(target_arch = "wasm32"))] +pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec, bool), String> { + crate::file_access::check_read(&path.to_string_lossy())?; + let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut out = Vec::new(); + for (seen, entry) in read.enumerate() { + if seen >= max { + return Ok((out, true)); + } + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if crate::file_access::check_read(&path.to_string_lossy()).is_err() { + continue; + } + let Ok(meta) = std::fs::metadata(&path) else { continue }; + if !meta.is_dir() && !meta.is_file() { + continue; + } + let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + let name = entry.file_name().to_string_lossy().into_owned(); + out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified }); + } + Ok((out, false)) +} + +/// The title in the core properties of the Word package at `path` (a file of at most `max_file` +/// bytes; at most `max_part` bytes of the part are read), inside the automation read root. +#[cfg(not(target_arch = "wasm32"))] +pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option { + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + let file = std::fs::File::open(path).ok()?; + if file.metadata().ok()?.len() > max_file { + return None; + } + wordcraft_docx::package_title(std::io::BufReader::new(file), max_part) +} + /// Rename without replacing another file, respecting both automation access roots. #[cfg(not(target_arch = "wasm32"))] pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 99d66247d..864ec0b72 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -552,6 +552,10 @@ pub struct Prefs { /// Proofing, #1063): an app started from Finder or the Dock doesn't see /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. pub dictionaries_folder: Option, + /// The user templates folder File › New lists personal templates from and Save as Word + /// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's + /// own `Templates` folder in its settings folder. + pub user_templates_folder: Option, /// Formatting marks shown even while Show/Hide ¶ is off. pub marks: wordcraft_layout::display::FormattingMarks, /// Mark grammar errors as you type (with Check spelling as you type on). @@ -577,6 +581,7 @@ impl Default for Prefs { units: Default::default(), region: None, dictionaries_folder: None, + user_templates_folder: None, marks: Default::default(), mark_grammar: true, ignore_uppercase: true, diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index 813af092d..415ab99d8 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -102,6 +102,7 @@ fn commands_that_read_a_path_are_held_to_the_read_root() { ("insert.picture", json!({"path": s(&base.join("outside/pic.png"))})), ("mailings.recipients", json!({"path": outside})), ("file.newFromTemplate", json!({"path": outside})), + ("file.listTemplates", json!({"dir": s(&base.join("outside"))})), ("review.compare", json!({"path": outside})), ("review.compare", json!({"text": "x", "original": outside})), ("file.open", json!({"path": outside})), diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index d79566ee7..1bcda1651 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -317,7 +317,8 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) { open_list(app, ui); } -/// New: the whole template gallery with a search box; no Recent list (that is on Home and Open). +/// New: the whole template gallery with a search box, then the personal templates (#2852); no +/// Recent list (that is on Home and Open). The search covers both. fn new_page(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); heading(ui, "New"); @@ -329,16 +330,173 @@ fn new_page(app: &mut WordApp, ui: &mut Ui) { } ui.add_space(18.0); let found = matching_templates(&query); - if found.is_empty() { + let (folder, personal) = personal_templates(app, ui); + let personal_found = matching_personal(&query, &personal); + if found.is_empty() && personal_found.is_empty() { ui.label(egui::RichText::new(tl!("No templates match your search.")).color(t.text_dim)); return; } - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(18.0, 18.0); - for (label, template) in found { - template_tile(ui, app, label, template); + if !found.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(18.0, 18.0); + for (label, template) in found { + template_tile(ui, app, label, template); + } + }); + } + // Personal: the user templates folder's templates; a folder per category, as in Word. + let Some(folder) = folder else { return }; + if !query.trim().is_empty() && personal_found.is_empty() { + return; + } + ui.add_space(24.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Personal")).font(semibold(16.0))); + ui.add_space(8.0); + if ui.small_button(tl!("Refresh")).on_hover_text(folder.to_string_lossy()).clicked() { + refresh_personal_templates(ui.ctx()); } }); + ui.add_space(8.0); + let error = ui.ctx().data(|d| d.get_temp::(personal_error_id())).unwrap_or_default(); + if !error.is_empty() { + ui.label(egui::RichText::new(error).color(ui.visuals().error_fg_color)); + ui.add_space(6.0); + } + if personal.is_empty() { + ui.label(egui::RichText::new(tl!("Templates you save in your templates folder show up here.")).color(t.text_dim)); + ui.label(egui::RichText::new(folder.to_string_lossy()).font(regular(11.5)).color(t.text_dim)); + return; + } + let mut categories: Vec<&str> = Vec::new(); + for p in &personal_found { + if !categories.contains(&p.category.as_str()) { + categories.push(&p.category); + } + } + for category in categories { + if !category.is_empty() { + ui.add_space(12.0); + ui.label(egui::RichText::new(category).font(medium(13.5)).color(t.text)); + ui.add_space(6.0); + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(18.0, 18.0); + for p in personal_found.iter().filter(|p| p.category == category) { + personal_tile(ui, app, p); + } + }); + } +} + +/// A personal template: what the gallery needs of a [`PersonalTemplate`] listing entry. +type PersonalTemplate = wordcraft_engine::cmd::user_templates::PersonalTemplate; + +/// The templates folder setting and listing File › New shows, and the pass it was last shown in. +#[derive(Clone)] +struct PersonalListing { + key: (Option, Option), + pass: u64, + folder: Option, + templates: Vec, +} + +fn personal_listing_id() -> egui::Id { + egui::Id::new("backstage_personal_templates") +} + +#[cfg(test)] +thread_local! { + /// How many times this thread has listed the templates folder (tests check it isn't every frame). + static PERSONAL_LISTINGS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The user templates folder and its templates (#2852). The folder is read when New is shown (so +/// a template saved meanwhile, or a folder chosen in Options, appears), when its setting changes +/// and on Refresh; never on every frame, as reading a big or synced folder would stall the window. +/// A folder that isn't there lists nothing. +fn personal_templates(app: &WordApp, ui: &Ui) -> (Option, Vec) { + let key = (app.session.prefs.user_templates_folder.clone(), app.services.templates_dir.clone()); + let pass = ui.ctx().cumulative_pass_nr(); + let cached = ui.ctx().data(|d| d.get_temp::(personal_listing_id())); + // Still on New since the last pass: the listing stands. + if let Some(mut l) = cached + && l.key == key + && l.pass.saturating_add(1) >= pass + { + l.pass = pass; + let out = (l.folder.clone(), l.templates.clone()); + ui.ctx().data_mut(|d| d.insert_temp(personal_listing_id(), l)); + return out; + } + let folder = app.user_templates_dir(); + let templates = match &folder { + Some(f) if f.is_dir() => { + #[cfg(test)] + PERSONAL_LISTINGS.with(|n| n.set(n.get().saturating_add(1))); + wordcraft_engine::cmd::user_templates::list_templates(&f.to_string_lossy()).map(|l| l.templates).unwrap_or_else(|e| { + log::debug!("personal templates: {e}"); + Vec::new() + }) + } + _ => Vec::new(), + }; + let l = PersonalListing { key, pass, folder: folder.clone(), templates: templates.clone() }; + ui.ctx().data_mut(|d| d.insert_temp(personal_listing_id(), l)); + (folder, templates) +} + +/// Read the templates folder again on the next pass (File › New › Personal's Refresh). +fn refresh_personal_templates(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::(personal_listing_id())); + ctx.request_repaint(); +} + +/// The personal templates whose name, title or category contains `query` (case-insensitive); +/// all of them for an empty query. +fn matching_personal(query: &str, list: &[PersonalTemplate]) -> Vec { + let q = query.trim().to_lowercase(); + let has = |s: &str| s.to_lowercase().contains(&q); + list.iter().filter(|p| q.is_empty() || has(&p.label) || has(&p.category) || p.title.as_deref().is_some_and(has)).cloned().collect() +} + +/// A personal template's tile: a page with the template mark (no thumbnail) and its name; a +/// click makes a new document from it. +fn personal_tile(ui: &mut Ui, app: &mut WordApp, p: &PersonalTemplate) { + let t = Tokens::get(ui.ctx()); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.set_max_width(150.0); + let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click()); + ui.painter().rect( + r, + 2.0, + egui::Color32::WHITE, + Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border_strong }), + egui::StrokeKind::Inside, + ); + let mark = egui::Color32::from_gray(150); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(44.0, 44.0)), "documentTemplate", mark, t.accent); + let resp = resp.on_hover_text(&p.path); + let caption = ui.add(egui::Label::new(egui::RichText::new(&p.label).font(regular(12.5))).truncate().sense(Sense::click())); + if resp.clicked() || caption.clicked() { + let failed = open_personal(app, &p.path).err(); + ui.ctx().data_mut(|d| d.insert_temp(personal_error_id(), failed.unwrap_or_default())); + } + }); +} + +/// Make a new document from the personal template at `path` and close File; a template that +/// can't be read keeps File open so the gallery can say why. +fn open_personal(app: &mut WordApp, path: &str) -> Result<(), String> { + app.run("file.newFromTemplate", json!({"path": path}))?; + app.ui.backstage = false; + app.canvas.want_focus = true; + Ok(()) +} + +fn personal_error_id() -> egui::Id { + egui::Id::new("backstage_personal_error") } fn open_list(app: &mut WordApp, ui: &mut Ui) { @@ -375,7 +533,10 @@ fn open_error_id() -> egui::Id { /// Drop the Recent list's open error once File is closed, so it doesn't come back next time. pub(crate) fn forget_open_error(ctx: &egui::Context) { - ctx.data_mut(|d| d.remove::(open_error_id())); + ctx.data_mut(|d| { + d.remove::(open_error_id()); + d.remove::(personal_error_id()); + }); } /// Open a file from the Recent list. A file that can't be opened (deleted, moved, unreadable) @@ -413,6 +574,15 @@ fn save_as_page(app: &mut WordApp, ui: &mut Ui) { if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() { app.save_as_dialog(); } + // A Word template in the user templates folder, which File › New lists under Personal (#2852). + ui.add_space(8.0); + if ui + .button(egui::RichText::new(tl!("Save as Word Template…")).font(medium(14.0))) + .on_hover_text(tl!("Saves a .dotx template in your templates folder. It shows up in File › New under Personal.")) + .clicked() + { + let _ = app.run("ui.saveAsTemplate", json!({})); + } } fn info_page(app: &mut WordApp, ui: &mut Ui) { @@ -733,6 +903,180 @@ mod tests { assert!(ids("no such template").is_empty()); } + fn personal_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("wc-ui-personal-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn write_template(path: &std::path::Path, text: &str) { + let mut s = wordcraft_engine::Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": text})).unwrap(); + wordcraft_engine::io::save_path(path, &s.doc).unwrap(); + } + + fn new_page_harness(templates: Option) -> egui_kittest::Harness<'static, WordApp> { + let services = Services { templates_dir: templates, ..Default::default() }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + a.run("ui.backstage", json!({"value": true, "page": "new"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1100.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h + } + + /// File › New lists the user templates folder's templates under Personal (#2852), by + /// category; the search covers them; a click makes an untitled document attached to the + /// template. + #[test] + fn new_lists_personal_templates_and_opens_one() { + let dir = personal_dir("gallery"); + let memo = dir.join("Team memo.dotx"); + write_template(&memo, "Memo from the template"); + std::fs::create_dir_all(dir.join("Legal")).unwrap(); + write_template(&dir.join("Legal").join("Contract.dotx"), "Contract"); + let mut h = new_page_harness(Some(dir.clone())); + assert!(h.query_by_label("Personal").is_some(), "New has a Personal section"); + assert!(h.query_by_label("Team memo").is_some(), "{:?}", h.state().user_templates_dir()); + assert!(h.query_by_label("Legal").is_some(), "a subfolder is a category"); + assert!(h.query_by_label("Report").is_some(), "the built-in templates stay"); + + // The search covers both. + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string())); + h.run_steps(3); + assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template"); + assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none()); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string())); + h.run_steps(3); + assert!(h.query_by_label("No templates match your search.").is_some()); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new())); + h.run_steps(3); + + h.get_by_label("Team memo").click(); + h.run_steps(4); + let a = h.state(); + assert!(!a.ui.backstage, "File closes"); + assert_eq!(a.session.path, None, "an untitled document"); + assert_eq!(a.session.doc.settings.attached_template.as_deref(), Some(memo.to_string_lossy().as_ref())); + assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Memo from the template")); + drop(h); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An empty (or not yet made) templates folder says where templates come from; no folder (the + /// web) shows no Personal section. Home keeps only the built-ins. + #[test] + fn personal_section_empty_state_and_no_folder() { + let dir = personal_dir("empty").join("Templates"); + let h = new_page_harness(Some(dir.clone())); + assert!(h.query_by_label("Templates you save in your templates folder show up here.").is_some()); + assert!(!dir.exists(), "listing doesn't make the folder"); + drop(h); + let h = new_page_harness(None); + assert!(h.query_by_label("Personal").is_none()); + } + + /// A folder chosen in File › Options › Advanced › File locations replaces the app's own. + #[test] + fn the_options_folder_replaces_the_default() { + let own = personal_dir("own"); + write_template(&own.join("Own.dotx"), "x"); + let chosen = personal_dir("chosen"); + write_template(&chosen.join("Chosen.dotx"), "y"); + let mut h = new_page_harness(Some(own.clone())); + assert!(h.query_by_label("Own").is_some()); + h.state_mut().run("file.options", json!({"userTemplatesFolder": chosen.to_string_lossy()})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Chosen").is_some(), "the chosen folder is listed"); + assert!(h.query_by_label("Own").is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&own); + let _ = std::fs::remove_dir_all(&chosen); + } + + /// A personal template replaces the document like New does: unsaved changes are asked about + /// first, and nothing is replaced until that is answered. + #[test] + fn a_personal_template_asks_about_unsaved_changes_first() { + let dir = personal_dir("dirty"); + let memo = dir.join("Memo.dotx"); + write_template(&memo, "From the template"); + let mut h = new_page_harness(Some(dir.clone())); + h.state_mut().run("document.setText", json!({"text": "Unsaved work"})).unwrap(); + assert!(h.state().session.dirty); + h.get_by_label("Memo").click(); + h.run_steps(3); + let body = |h: &egui_kittest::Harness<'static, WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); + assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "Save changes is asked"); + assert_eq!(body(&h), "Unsaved work", "the document stays until the question is answered"); + h.state_mut().run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + h.run_steps(2); + assert_eq!(body(&h), "Unsaved work", "Cancel keeps it"); + assert_eq!(h.state().session.doc.settings.attached_template, None); + h.state_mut().run("ui.backstage", json!({"value": true, "page": "new"})).unwrap(); + h.run_steps(3); + h.get_by_label("Memo").click(); + h.run_steps(3); + h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + h.run_steps(2); + assert!(body(&h).contains("From the template"), "Don't Save makes the document from the template"); + assert_eq!(h.state().session.doc.settings.attached_template.as_deref(), Some(memo.to_string_lossy().as_ref())); + drop(h); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The templates folder is read when New is shown and on Refresh, not on every frame. + #[test] + fn personal_templates_are_listed_once_per_visit_and_on_refresh() { + let dir = personal_dir("refresh"); + write_template(&dir.join("First.dotx"), "x"); + let count = || super::PERSONAL_LISTINGS.with(std::cell::Cell::get); + let before = count(); + let mut h = new_page_harness(Some(dir.clone())); + let after_open = count(); + assert_eq!(after_open, before + 1, "listed once when New is shown"); + write_template(&dir.join("Second.dotx"), "y"); + h.run_steps(20); + assert_eq!(count(), after_open, "not listed again while New stays open"); + assert!(h.query_by_label("Second").is_none()); + h.get_by_label("Refresh").click(); + h.run_steps(3); + assert_eq!(count(), after_open + 1, "Refresh lists once"); + assert!(h.query_by_label("Second").is_some(), "Refresh shows the new template"); + // Leaving New and coming back reads the folder again. + h.state_mut().run("ui.backstage", json!({"value": true, "page": "open"})).unwrap(); + h.run_steps(2); + write_template(&dir.join("Third.dotx"), "z"); + h.state_mut().run("ui.backstage", json!({"value": true, "page": "new"})).unwrap(); + h.run_steps(3); + assert_eq!(count(), after_open + 2); + assert!(h.query_by_label("Third").is_some()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A saved templates folder that is a network path, or gone, gives way to the app's own. + #[test] + fn an_unusable_saved_folder_falls_back_to_the_apps_own() { + let own = personal_dir("fallback"); + let services = Services { templates_dir: Some(own.clone()), ..Default::default() }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + for bad in [r"\\host\share".to_string(), "//host/share".into(), own.join("gone").to_string_lossy().into_owned(), "relative".into()] { + a.session.prefs.user_templates_folder = Some(bad.clone()); + assert_eq!(a.user_templates_dir(), Some(own.clone()), "{bad}"); + } + a.session.prefs.user_templates_folder = Some(own.to_string_lossy().into_owned()); + assert_eq!(a.user_templates_dir(), Some(own.clone())); + let _ = std::fs::remove_dir_all(&own); + } + /// Home and New are different pages (#141): Home has the short template row, the link to New /// and Recent; New has every template and no Recent list. #[test] diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 0b43f5c58..502d6a3aa 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -150,7 +150,7 @@ impl FileBrowser { FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => { (String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect()) } - FileDialogRequest::Save { name } => { + FileDialogRequest::Save { name, .. } => { let ext = Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty()); let mut f: Vec<(String, Vec)> = ext.map(|e| (format!("*.{e}"), vec![e.to_string()])).into_iter().collect(); f.push(("All files".into(), vec!["*".into()])); @@ -258,11 +258,13 @@ impl FileBrowser { impl WordApp { /// Ask for a file in the built-in browser and do `after` with the answer when it closes. pub(crate) fn show_file_browser(&mut self, req: FileDialogRequest, after: AfterPick, unavailable: bool) { - let start = self - .file_browser - .last_dir - .clone() - .filter(|d| d.is_dir()) + // A folder the request names (the user templates folder, #2852) comes first. + let asked = match &req { + FileDialogRequest::Save { dir: Some(d), .. } => Some(PathBuf::from(d)).filter(|d| d.is_dir()), + _ => None, + }; + let start = asked + .or_else(|| self.file_browser.last_dir.clone().filter(|d| d.is_dir())) .or_else(|| self.session.path.as_ref().and_then(|p| p.parent()).map(Path::to_path_buf).filter(|d| d.is_dir())) .or_else(home) .or_else(|| std::env::current_dir().ok()) @@ -281,7 +283,7 @@ impl WordApp { "picture" => (FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture), "recipients" => (FileDialogRequest::Open { purpose: "recipients".into() }, AfterPick::Recipients), "save" => ( - FileDialogRequest::Save { name: self.default_save_name() }, + FileDialogRequest::Save { name: self.default_save_name(), dir: None }, AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() }, ), _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument), @@ -526,7 +528,7 @@ mod tests { let dir = scratch("save"); std::fs::create_dir(dir.join("drafts")).unwrap(); std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap(); - let req = FileDialogRequest::Save { name: "Document1.docx".into() }; + let req = FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }; let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false); b.name = "drafts".into(); assert_eq!(b.accept(), Step::Stay); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 90db7e56b..207c6adf4 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -25,8 +25,9 @@ pub enum FileDialogRequest { Open { purpose: String }, /// Pick one or more files (Add Font…); the answer lists every one picked. OpenMany { purpose: String }, - /// Pick a path to save to, given a suggested file name. - Save { name: String }, + /// Pick a path to save to, given a suggested file name and, if it matters, the folder to + /// start in (Save as Word Template starts in the user templates folder, #2852). + Save { name: String, dir: Option }, } /// The host's answer to a [`FileDialogRequest`]: the picked paths (one for everything but @@ -145,7 +146,7 @@ impl WordApp { FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => { self.services.pick_open.as_ref().and_then(|f| f(purpose)) } - FileDialogRequest::Save { name } => self.services.pick_save.as_ref().and_then(|f| f(name)), + FileDialogRequest::Save { name, .. } => self.services.pick_save.as_ref().and_then(|f| f(name)), }; Asked::Done(self.finish_pick(after, picked)) } @@ -354,14 +355,52 @@ impl WordApp { /// Ask where to save, save there, then do `after`. pub(crate) fn save_as(&mut self, after: AfterSave) -> Asked { let name = self.default_save_name(); - self.ask_file(FileDialogRequest::Save { name }, AfterPick::SaveAs { after, document: self.session.document_id() }) + self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::SaveAs { after, document: self.session.document_id() }) + } + + /// The user templates folder (#2852): the one File › Options names, else the app's own. + /// `None` on the web, which has no folders. + pub fn user_templates_dir(&self) -> Option { + // The saved folder is checked again (the settings file may have been edited, the folder + // removed): a network folder is never looked at, and one that's gone gives way to the + // app's own. + let saved = self.session.prefs.user_templates_folder.as_deref().map(str::trim).filter(|f| !f.is_empty()); + match saved { + Some(f) if wordcraft_engine::cmd::user_templates::check_dir(f).is_ok() && std::path::Path::new(f).is_dir() => { + Some(std::path::PathBuf::from(f)) + } + Some(f) => { + log::debug!("user templates folder {f:?} isn't usable; using the app's own"); + self.services.templates_dir.clone() + } + None => self.services.templates_dir.clone(), + } + } + + /// File › Save As › Save as Word Template (#2852): ask where to save a `.dotx`, starting in + /// the user templates folder (the app's own is made on first use), then save there as Save As + /// does. Returns the folder the dialog starts in. + pub(crate) fn save_as_template_dialog(&mut self) -> Option { + let dir = self.user_templates_dir(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(d) = &dir + && !d.is_dir() + && let Err(e) = std::fs::create_dir_all(d) + { + log::warn!("couldn't make the templates folder {}: {e}", d.display()); + } + let dir = dir.filter(|d| d.is_dir()).map(|d| d.to_string_lossy().into_owned()); + let name = format!("{}.dotx", self.title_stem()); + let after = AfterPick::SaveAs { after: AfterSave::Nothing, document: self.session.document_id() }; + let _ = self.ask_file(FileDialogRequest::Save { name, dir: dir.clone() }, after); + dir } /// File › Export: ask where to save a copy in this format, then write it. pub(crate) fn export_dialog(&mut self, ext: &str) { let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext }; let name = format!("{}.{file_ext}", self.title_stem()); - let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() }); + let _ = self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::Export { ext: ext.to_string() }); } /// File › Print › Save these pages as PDF: ask where, then write the pages the print @@ -369,7 +408,7 @@ impl WordApp { pub(crate) fn print_pdf_dialog(&mut self) { let name = format!("{}.pdf", self.title_stem()); let settings = self.ui.print.params(); - let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::PrintPdf { settings }); + let _ = self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::PrintPdf { settings }); } pub(crate) fn open_dialog(&mut self) { @@ -465,7 +504,7 @@ mod tests { let revision = a.session.rev(); let result = a.run("file.save", json!({})).unwrap(); assert_eq!(result["pending"], "saveAs"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() }); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); answer(&shown, None); assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], false); assert_eq!(a.session.doc, draft); @@ -614,7 +653,7 @@ mod tests { h.get_by_label("Save").click(); h.run_steps(3); assert!(h.state().file_dialog_open(), "the Save button asks for a supported target"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() }); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); answer(&shown, None); h.run_steps(3); @@ -625,7 +664,7 @@ mod tests { h.event(egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); h.run_steps(3); assert!(h.state().file_dialog_open(), "the focused editor's Mod+S asks too"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() }); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); answer(&shown, None); h.run_steps(3); assert!(h.state().session.dirty); @@ -682,6 +721,42 @@ mod tests { wordcraft_engine::io::open_path(path).unwrap().plain_text(wordcraft_doc::StoryRef::Body) } + /// Save as Word Template (#2852) suggests a .dotx in the user templates folder, which is made + /// on first use; the picked path is saved as a template. A folder named in File › Options + /// replaces the app's own. + #[test] + fn save_as_template_starts_in_the_templates_folder() { + let dir = scratch("save-template"); + let own = dir.join("Templates"); + let (mut a, shown) = async_app(); + a.services.templates_dir = Some(own.clone()); + typed(&mut a); + let r = a.run("ui.saveAsTemplate", json!({})).unwrap(); + assert!(own.is_dir(), "the app's templates folder is made on first use"); + assert_eq!(r["dir"], json!(own.to_string_lossy())); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.dotx".into(), dir: Some(own.to_string_lossy().into_owned()) }); + let path = own.join("Memo.dotx"); + answer(&shown, Some(&path.to_string_lossy())); + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); + assert!(path.is_file(), "saved as a template"); + assert_eq!(saved_text(&path), UNSAVED); + let listed = wordcraft_engine::cmd::user_templates::list_templates(&own.to_string_lossy()).unwrap(); + assert_eq!(listed.templates.iter().map(|t| t.label.as_str()).collect::>(), ["Memo"]); + + // A folder chosen in Options wins. + let chosen = dir.join("Chosen"); + std::fs::create_dir_all(&chosen).unwrap(); + a.run("file.options", json!({"userTemplatesFolder": chosen.to_string_lossy()})).unwrap(); + assert_eq!(a.user_templates_dir(), Some(chosen.clone())); + a.run("ui.saveAsTemplate", json!({})).unwrap(); + assert!( + matches!(&shown.borrow()[0].0, FileDialogRequest::Save { name, dir: Some(d) } if name == "Memo.dotx" && *d == chosen.to_string_lossy()) + ); + answer(&shown, None); + let _ = std::fs::remove_dir_all(&dir); + } + /// Save As returns at once (the UI keeps running); the frame loop saves once the answer arrives. #[test] fn save_as_answer_arriving_later_is_saved() { @@ -691,7 +766,7 @@ mod tests { typed(&mut a); a.save_as_dialog(); assert!(a.file_dialog_open()); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into() }); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }); let ctx = egui::Context::default(); let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); frame(&mut a); @@ -729,7 +804,7 @@ mod tests { let r = a.run("file.saveAs", json!({})).unwrap(); assert_eq!(r, json!({"saved": false})); assert!(a.file_dialog_open(), "F12's saveAs request opens the dialog"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into() }); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into(), dir: None }); let _ = std::fs::remove_dir_all(&dir); } @@ -759,7 +834,7 @@ mod tests { let (mut a, shown) = async_app(); let r = a.run("file.exportPdf", json!({})).unwrap(); assert_eq!(r, json!({"exported": false})); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{}.pdf", a.title_stem()) }); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{}.pdf", a.title_stem()), dir: None }); let dir = scratch("export-pdf"); let picked = dir.join("out.pdf"); answer(&shown, Some(&picked.to_string_lossy())); @@ -784,7 +859,7 @@ mod tests { answer(&shown, None); a.poll_file_dialog(); a.run("file.exportPng", json!({})).unwrap(); - assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { ref name } if name.ends_with(".png"))); + assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { ref name, .. } if name.ends_with(".png"))); } /// #1445: "Save changes?" → Save on a new document while another file dialog is still open: diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index cae0e694c..032467cf6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2697,6 +2697,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Въведете размер в произволна единица, например 5 cm или 300 px. Другата страна се променя съответно, така че картината запазва пропорциите си. Import… Импортиране… + +# Personal templates (#2852) + Personal Лични + Personal Templates Лични шаблони + Templates you save in your templates folder show up here. Шаблоните, които запишете в папката си с шаблони, се показват тук. + Save as Word Template… Запиши като шаблон на Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Записва шаблон .dotx в папката ви с шаблони. Той се показва във Файл › Нов под Лични. + File locations Местоположения на файлове + User templates: Потребителски шаблони: + Not set Не е зададено + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Файл › Нов показва шаблоните .dotx и .dotm оттук под Лични; „Запиши като шаблон на Word“ започва оттук. Format Shape Pane Екран „Форматиране на фигура“ Format Shape Форматиране на фигура Format Shape… Форматиране на фигура… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 887e81fd3..3a210cc85 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2700,6 +2700,17 @@ preview Title Ukázka Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Zadejte velikost v libovolné jednotce, například 5 cm nebo 300 px. Druhá strana se přizpůsobí, takže obrázek zachová své proporce. Import… Importovat… + +# Personal templates (#2852) + Personal Osobní + Personal Templates Osobní šablony + Templates you save in your templates folder show up here. Zde se zobrazí šablony, které uložíte do složky šablon. + Save as Word Template… Uložit jako šablonu Wordu… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Uloží šablonu .dotx do vaší složky šablon. Zobrazí se v nabídce Soubor › Nový v části Osobní. + File locations Umístění souborů + User templates: Uživatelské šablony: + Not set Nenastaveno + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Soubor › Nový zobrazuje šablony .dotx a .dotm odsud v části Osobní; Uložit jako šablonu Wordu začíná zde. Format Shape Pane Podokno Formát obrazce Format Shape Formát obrazce Format Shape… Formátovat obrazec… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7009f1bc0..3fe150cca 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2296,6 +2296,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Geben Sie eine Größe in einer beliebigen Einheit ein, z. B. 5 cm oder 300 px. Die andere Seite passt sich an, sodass das Bild seine Proportionen behält. Import… Importieren… + +# Personal templates (#2852) + Personal Persönlich + Personal Templates Persönliche Vorlagen + Templates you save in your templates folder show up here. Vorlagen, die Sie in Ihrem Vorlagenordner speichern, werden hier angezeigt. + Save as Word Template… Als Word-Vorlage speichern… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Speichert eine .dotx-Vorlage in Ihrem Vorlagenordner. Sie erscheint unter Datei › Neu bei Persönlich. + File locations Dateispeicherorte + User templates: Benutzervorlagen: + Not set Nicht festgelegt + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Datei › Neu zeigt die .dotx- und .dotm-Vorlagen von hier unter Persönlich; Als Word-Vorlage speichern beginnt hier. Format Shape Pane Bereich „Form formatieren“ Format Shape Form formatieren Format Shape… Form formatieren… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 010c73156..3431633a9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2663,6 +2663,17 @@ preview Title Título Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Escribe un tamaño en cualquier unidad, como 5 cm o 300 px. El otro lado se ajusta, así que la imagen conserva sus proporciones. Import… Importar… + +# Personal templates (#2852) + Personal Personal + Personal Templates Plantillas personales + Templates you save in your templates folder show up here. Las plantillas que guarde en su carpeta de plantillas aparecen aquí. + Save as Word Template… Guardar como plantilla de Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Guarda una plantilla .dotx en su carpeta de plantillas. Aparece en Archivo › Nuevo, en Personal. + File locations Ubicación de archivos + User templates: Plantillas de usuario: + Not set Sin definir + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Archivo › Nuevo muestra aquí las plantillas .dotx y .dotm en Personal; Guardar como plantilla de Word empieza aquí. Format Shape Pane Panel Formato de forma Format Shape Formato de forma Format Shape… Formato de forma… diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index e3e470ca9..263f5dace 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2331,6 +2331,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Sisesta suurus mis tahes ühikus, näiteks 5 cm või 300 px. Teine külg muutub kaasa, nii et pilt säilitab oma proportsioonid. Import… Impordi… + +# Personal templates (#2852) + Personal Isiklik + Personal Templates Isiklikud mallid + Templates you save in your templates folder show up here. Siin kuvatakse mallid, mille salvestate oma mallide kausta. + Save as Word Template… Salvesta Wordi mallina… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Salvestab .dotx-malli teie mallide kausta. See kuvatakse jaotises Fail › Uus all Isiklik. + File locations Failide asukohad + User templates: Kasutaja mallid: + Not set Määramata + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Fail › Uus loetleb siinsed .dotx- ja .dotm-mallid jaotises Isiklik; Salvesta Wordi mallina alustab siit. Format Shape Pane Kujundi vormindamise paan Format Shape Kujundi vormindamine Format Shape… Vorminda kujund… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 14cc85f32..6ebff61ee 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2709,6 +2709,17 @@ template Letter Lettre Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Tapez une taille dans n'importe quelle unité, par exemple 5 cm ou 300 px. L'autre côté suit, et l'image garde ses proportions. Import… Importer… + +# Personal templates (#2852) + Personal Personnel + Personal Templates Modèles personnels + Templates you save in your templates folder show up here. Les modèles que vous enregistrez dans votre dossier de modèles apparaissent ici. + Save as Word Template… Enregistrer comme modèle Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Enregistre un modèle .dotx dans votre dossier de modèles. Il apparaît dans Fichier › Nouveau, sous Personnel. + File locations Dossiers par défaut + User templates: Modèles utilisateur : + Not set Non défini + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Fichier › Nouveau affiche les modèles .dotx et .dotm de ce dossier sous Personnel ; Enregistrer comme modèle Word commence ici. Format Shape Pane Volet Mise en forme de la forme Format Shape Mise en forme de la forme Format Shape… Mise en forme de la forme… diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 9571b95b5..404dcae7b 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2708,6 +2708,17 @@ preview Title Naslov Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Upišite veličinu u bilo kojoj jedinici, npr. 5 cm ili 300 px. Druga se strana prilagođava pa slika zadržava svoje proporcije. Import… Uvezi… + +# Personal templates (#2852) + Personal Osobno + Personal Templates Osobni predlošci + Templates you save in your templates folder show up here. Ovdje se prikazuju predlošci koje spremite u mapu predložaka. + Save as Word Template… Spremi kao Wordov predložak… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Sprema predložak .dotx u vašu mapu predložaka. Prikazuje se u Datoteka › Novo pod Osobno. + File locations Mjesta datoteka + User templates: Korisnički predlošci: + Not set Nije postavljeno + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Datoteka › Novo prikazuje predloške .dotx i .dotm odavde pod Osobno; Spremi kao Wordov predložak počinje ovdje. Format Shape Pane Okno Oblikovanje oblika Format Shape Oblikovanje oblika Format Shape… Oblikuj oblik… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cf6ac1ee7..0e2b13a52 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2704,6 +2704,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Írj be egy méretet bármilyen mértékegységben, például 5 cm vagy 300 px. A másik oldal követi, így a kép megtartja az arányait. Import… Importálás… + +# Personal templates (#2852) + Personal Saját + Personal Templates Saját sablonok + Templates you save in your templates folder show up here. A sablonmappájába mentett sablonok itt jelennek meg. + Save as Word Template… Mentés Word-sablonként… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Egy .dotx sablont ment a sablonmappájába. A Fájl › Új lapon, a Saját csoportban jelenik meg. + File locations Fájlok helye + User templates: Felhasználói sablonok: + Not set Nincs megadva + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. A Fájl › Új a Saját csoportban sorolja fel az itteni .dotx és .dotm sablonokat; a Mentés Word-sablonként innen indul. Format Shape Pane Alakzat formázása munkaablak Format Shape Alakzat formázása Format Shape… Alakzat formázása… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 783d13db6..e2c47a0a4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2717,6 +2717,17 @@ preview Title Esempio Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Scrivi una dimensione in qualsiasi unità, ad esempio 5 cm o 300 px. L'altro lato si adegua, così l'immagine mantiene le proporzioni. Import… Importa… + +# Personal templates (#2852) + Personal Personali + Personal Templates Modelli personali + Templates you save in your templates folder show up here. I modelli che salvi nella cartella dei modelli compaiono qui. + Save as Word Template… Salva come modello di Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Salva un modello .dotx nella cartella dei modelli. Compare in File › Nuovo, in Personali. + File locations Percorsi dei file + User templates: Modelli utente: + Not set Non impostato + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. File › Nuovo elenca qui i modelli .dotx e .dotm in Personali; Salva come modello di Word parte da qui. Format Shape Pane Riquadro Formato forma Format Shape Formato forma Format Shape… Formato forma… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 19e5d3921..50a6a038c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2297,6 +2297,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm や 300 px など、任意の単位でサイズを入力します。もう一方の辺も合わせて変わるため、図の縦横比は保たれます。 Import… インポート… + +# Personal templates (#2852) + Personal 個人用 + Personal Templates 個人用テンプレート + Templates you save in your templates folder show up here. テンプレート フォルダーに保存したテンプレートがここに表示されます。 + Save as Word Template… Word テンプレートとして保存… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. .dotx テンプレートをテンプレート フォルダーに保存します。[ファイル] › [新規] の [個人用] に表示されます。 + File locations ファイルの場所 + User templates: ユーザー テンプレート: + Not set 未設定 + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. [ファイル] › [新規] の [個人用] に、ここにある .dotx と .dotm のテンプレートが表示されます。[Word テンプレートとして保存] はここから始まります。 Format Shape Pane 図形の書式設定ウィンドウ Format Shape 図形の書式設定 Format Shape… 図形の書式設定… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 79d75710c..0ef015a5e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2551,6 +2551,17 @@ preview Title 제목 Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm 또는 300 px처럼 원하는 단위로 크기를 입력하세요. 다른 쪽도 함께 바뀌어 그림의 비율이 유지됩니다. Import… 가져오기… + +# Personal templates (#2852) + Personal 개인 + Personal Templates 개인 서식 파일 + Templates you save in your templates folder show up here. 서식 파일 폴더에 저장한 서식 파일이 여기에 표시됩니다. + Save as Word Template… Word 서식 파일로 저장… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. .dotx 서식 파일을 서식 파일 폴더에 저장합니다. 파일 › 새로 만들기의 개인 아래에 표시됩니다. + File locations 파일 위치 + User templates: 사용자 서식 파일: + Not set 설정 안 됨 + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. 파일 › 새로 만들기의 개인 아래에 이 폴더의 .dotx 및 .dotm 서식 파일이 표시됩니다. Word 서식 파일로 저장은 여기에서 시작합니다. Format Shape Pane 도형 서식 창 Format Shape 도형 서식 Format Shape… 도형 서식… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index de4a49421..9d29c2a55 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2713,6 +2713,17 @@ preview Title Tytuł Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Wpisz rozmiar w dowolnej jednostce, np. 5 cm lub 300 px. Druga strona dopasuje się, więc obraz zachowa proporcje. Import… Importuj… + +# Personal templates (#2852) + Personal Osobiste + Personal Templates Szablony osobiste + Templates you save in your templates folder show up here. Tutaj pojawiają się szablony zapisane w folderze szablonów. + Save as Word Template… Zapisz jako szablon programu Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Zapisuje szablon .dotx w folderze szablonów. Pojawia się w Plik › Nowy w sekcji Osobiste. + File locations Lokalizacje plików + User templates: Szablony użytkownika: + Not set Nie ustawiono + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Plik › Nowy pokazuje stąd szablony .dotx i .dotm w sekcji Osobiste; Zapisz jako szablon programu Word zaczyna tutaj. Format Shape Pane Okienko Formatowanie kształtu Format Shape Formatowanie kształtu Format Shape… Formatuj kształt… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6f6c540b6..2f099becf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2309,6 +2309,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Digite um tamanho em qualquer unidade, como 5 cm ou 300 px. O outro lado acompanha, e a imagem mantém as proporções. Import… Importar… + +# Personal templates (#2852) + Personal Pessoal + Personal Templates Modelos pessoais + Templates you save in your templates folder show up here. Os modelos que você salvar na sua pasta de modelos aparecem aqui. + Save as Word Template… Salvar como modelo do Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Salva um modelo .dotx na sua pasta de modelos. Ele aparece em Arquivo › Novo, em Pessoal. + File locations Locais de arquivos + User templates: Modelos do usuário: + Not set Não definido + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Arquivo › Novo lista os modelos .dotx e .dotm daqui em Pessoal; Salvar como modelo do Word começa aqui. Format Shape Pane Painel Formatar Forma Format Shape Formatar Forma Format Shape… Formatar Forma… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e78f18748..b9c89ce61 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2706,6 +2706,17 @@ preview Title Пример Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введите размер в любых единицах, например 5 cm или 300 px. Другая сторона изменится вместе с ней, и рисунок сохранит пропорции. Import… Импорт… + +# Personal templates (#2852) + Personal Личные + Personal Templates Личные шаблоны + Templates you save in your templates folder show up here. Здесь появляются шаблоны, сохранённые в папку шаблонов. + Save as Word Template… Сохранить как шаблон Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Сохраняет шаблон .dotx в папку шаблонов. Он появится в разделе Файл › Создать, в группе «Личные». + File locations Расположение файлов + User templates: Шаблоны пользователя: + Not set Не задано + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Файл › Создать показывает отсюда шаблоны .dotx и .dotm в группе «Личные»; «Сохранить как шаблон Word» начинает здесь. Format Shape Pane Область «Формат фигуры» Format Shape Формат фигуры Format Shape… Формат фигуры… diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 4bddd9208..b492b07d3 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2307,6 +2307,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Unesite veličinu u bilo kojoj jedinici, na primer 5 cm ili 300 px. Druga strana se prilagođava, pa slika zadržava svoje proporcije. Import… Uvezi… + +# Personal templates (#2852) + Personal Lični + Personal Templates Lični šabloni + Templates you save in your templates folder show up here. Ovde se prikazuju šabloni koje sačuvate u fascikli šablona. + Save as Word Template… Sačuvaj kao Word šablon… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Čuva .dotx šablon u fascikli šablona. Prikazuje se u Fajl › Novo pod Lični. + File locations Lokacije datoteka + User templates: Korisnički šabloni: + Not set Nije postavljeno + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Fajl › Novo prikazuje .dotx i .dotm šablone odavde pod Lični; Sačuvaj kao Word šablon počinje ovde. Format Shape Pane Okno „Format oblika“ Format Shape Format oblika Format Shape… Formatiraj oblik… diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 1bf6f8d3e..8a0cff04e 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2311,6 +2311,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Унесите величину у било којој јединици, на пример 5 cm или 300 px. Друга страна се прилагођава, па слика задржава своје пропорције. Import… Увези… + +# Personal templates (#2852) + Personal Лични + Personal Templates Лични шаблони + Templates you save in your templates folder show up here. Овде се приказују шаблони које сачувате у фасцикли шаблона. + Save as Word Template… Сачувај као Word шаблон… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Чува .dotx шаблон у фасцикли шаблона. Приказује се у Фајл › Ново под Лични. + File locations Локације датотека + User templates: Кориснички шаблони: + Not set Није постављено + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Фајл › Ново приказује .dotx и .dotm шаблоне одавде под Лични; Сачувај као Word шаблон почиње овде. Format Shape Pane Окно „Формат облика“ Format Shape Формат облика Format Shape… Форматирај облик… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 60c650d9d..ea569c189 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2723,6 +2723,17 @@ preview Title Titel Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Skriv en storlek i valfri enhet, till exempel 5 cm eller 300 px. Den andra sidan följer med, så att bilden behåller sina proportioner. Import… Importera… + +# Personal templates (#2852) + Personal Personliga + Personal Templates Personliga mallar + Templates you save in your templates folder show up here. Mallar som du sparar i din mallmapp visas här. + Save as Word Template… Spara som Word-mall… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Sparar en .dotx-mall i din mallmapp. Den visas i Arkiv › Ny under Personliga. + File locations Filplatser + User templates: Användarmallar: + Not set Inte angivet + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Arkiv › Ny visar .dotx- och .dotm-mallarna härifrån under Personliga; Spara som Word-mall börjar här. Format Shape Pane Fönstret Formatera form Format Shape Formatera form Format Shape… Formatera form… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ea5571bff..17859beae 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2705,6 +2705,17 @@ preview Title Örnek Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Herhangi bir birimde bir boyut yazın, örneğin 5 cm veya 300 px. Diğer kenar da buna uyar, böylece resim oranlarını korur. Import… İçe aktar… + +# Personal templates (#2852) + Personal Kişisel + Personal Templates Kişisel şablonlar + Templates you save in your templates folder show up here. Şablon klasörünüze kaydettiğiniz şablonlar burada görünür. + Save as Word Template… Word şablonu olarak kaydet… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Şablon klasörünüze bir .dotx şablonu kaydeder. Dosya › Yeni altında Kişisel bölümünde görünür. + File locations Dosya konumları + User templates: Kullanıcı şablonları: + Not set Ayarlanmadı + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Dosya › Yeni, buradaki .dotx ve .dotm şablonlarını Kişisel altında listeler; Word şablonu olarak kaydet buradan başlar. Format Shape Pane Şekli Biçimlendir bölmesi Format Shape Şekli Biçimlendir Format Shape… Şekli Biçimlendir… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 565f6b4e8..859b28e75 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2309,6 +2309,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введіть розмір у будь-яких одиницях, наприклад 5 cm або 300 px. Інша сторона зміниться разом із нею, тож зображення збереже пропорції. Import… Імпортувати… + +# Personal templates (#2852) + Personal Особисті + Personal Templates Особисті шаблони + Templates you save in your templates folder show up here. Тут з’являються шаблони, які ви зберігаєте в теці шаблонів. + Save as Word Template… Зберегти як шаблон Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Зберігає шаблон .dotx у вашій теці шаблонів. Він з’являється в розділі Файл › Новий, у групі «Особисті». + File locations Розташування файлів + User templates: Шаблони користувача: + Not set Не задано + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Файл › Новий показує звідси шаблони .dotx і .dotm у групі «Особисті»; «Зберегти як шаблон Word» починає тут. Format Shape Pane Область «Формат фігури» Format Shape Формат фігури Format Shape… Формат фігури… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fc117f346..a1af28ce8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2748,6 +2748,17 @@ template Letter Thư Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Nhập kích thước theo đơn vị bất kỳ, ví dụ 5 cm hoặc 300 px. Cạnh còn lại thay đổi theo, nên ảnh giữ nguyên tỷ lệ. Import… Nhập… + +# Personal templates (#2852) + Personal Cá nhân + Personal Templates Mẫu cá nhân + Templates you save in your templates folder show up here. Các mẫu bạn lưu vào thư mục mẫu sẽ xuất hiện ở đây. + Save as Word Template… Lưu dưới dạng mẫu Word… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Lưu một mẫu .dotx vào thư mục mẫu của bạn. Mẫu xuất hiện trong Tệp › Mới, mục Cá nhân. + File locations Vị trí tệp + User templates: Mẫu người dùng: + Not set Chưa đặt + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Tệp › Mới liệt kê các mẫu .dotx và .dotm ở đây trong mục Cá nhân; Lưu dưới dạng mẫu Word bắt đầu từ đây. Format Shape Pane Ngăn Định dạng hình Format Shape Định dạng hình Format Shape… Định dạng hình… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 25cf725fc..c081e34bb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2309,6 +2309,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任意单位输入大小,例如 5 cm 或 300 px。另一边随之调整,图片保持原有比例。 Import… 导入… + +# Personal templates (#2852) + Personal 个人 + Personal Templates 个人模板 + Templates you save in your templates folder show up here. 保存到模板文件夹中的模板会显示在这里。 + Save as Word Template… 另存为 Word 模板… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. 将 .dotx 模板保存到你的模板文件夹。它会显示在“文件 › 新建”的“个人”下。 + File locations 文件位置 + User templates: 用户模板: + Not set 未设置 + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. “文件 › 新建”在“个人”下列出此处的 .dotx 和 .dotm 模板;“另存为 Word 模板”从这里开始。 Format Shape Pane 设置形状格式窗格 Format Shape 设置形状格式 Format Shape… 设置形状格式… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c44663c9..0780f108f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2298,6 +2298,17 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任何單位輸入大小,例如 5 cm 或 300 px。另一邊隨之調整,圖片保持原有比例。 Import… 匯入… + +# Personal templates (#2852) + Personal 個人 + Personal Templates 個人範本 + Templates you save in your templates folder show up here. 儲存到範本資料夾的範本會顯示在這裡。 + Save as Word Template… 另存為 Word 範本… + Saves a .dotx template in your templates folder. It shows up in File › New under Personal. 將 .dotx 範本儲存到你的範本資料夾。它會顯示在「檔案 › 新增」的「個人」下。 + File locations 檔案位置 + User templates: 使用者範本: + Not set 未設定 + File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. 「檔案 › 新增」會在「個人」下列出此處的 .dotx 和 .dotm 範本;「另存為 Word 範本」從這裡開始。 Format Shape Pane 設定圖形格式窗格 Format Shape 設定圖形格式 Format Shape… 設定圖形格式… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8a078f86e..6c9cde7b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -173,6 +173,10 @@ pub struct Services { /// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder /// instead. An error means nothing changed. pub make_default_app: Option Result>>, + /// Desktop: the app's own user templates folder (`Templates` in the settings folder, #2852), + /// used while File › Options names none. File › New lists personal templates from it and Save + /// as Word Template starts in it. `None` (web) hides personal templates. + pub templates_dir: Option, } /// How Save in the Save Changes prompt went. @@ -1676,6 +1680,12 @@ impl WordApp { self.open_dialog(); json!({}) } + // File › Save As › Save as Word Template (#2852): the Save dialog for a .dotx, in the + // user templates folder. Opens system UI, like `ui.openFileDialog`. + "ui.saveAsTemplate" => { + let dir = self.save_as_template_dialog(); + json!({"dir": dir}) + } // The built-in file browser (#576), for when the system's file dialog doesn't show up // (desktop only): `purpose` is document (default), picture, recipients or save. A system // dialog still waiting for an answer is given up as cancelled. @@ -2629,6 +2639,8 @@ pub(crate) fn works_without_document(id: &str) -> bool { matches!( id, "file.new" + | "file.newFromTemplate" + | "file.listTemplates" | "file.open" | "file.close" | "file.options" @@ -2685,13 +2697,13 @@ fn flushes_comment_drafts(id: &str, params: &Value) -> bool { ) || discards_document(id, params) } -/// User commands that replace or close the document (`file.open` without a path only shows the +/// User commands that replace or close the document (New, also from a template; `file.open` without a path only shows the /// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a /// new document in its place; a merge written to a file (`path`) and an envelope added to this /// document (`add`) leave it alone. pub(crate) fn discards_document(id: &str, params: &Value) -> bool { match id { - "file.new" | "file.close" | "mailings.labels" => true, + "file.new" | "file.newFromTemplate" | "file.close" | "mailings.labels" => true, "mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true), "file.open" => params.get("path").is_some(), "file.recover" => params.get("restore").is_some(), diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 09105193f..2962187d6 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -531,6 +531,12 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { { let _ = app.run("ui.caretAnimation", json!({"value": caret})); } + // The web app has no folders to name. + if !cfg!(target_arch = "wasm32") { + ui.add_space(8.0); + section(ui, "File locations"); + templates_folder(app, ui); + } ui.add_space(8.0); section(ui, "Customize"); ui.horizontal(|ui| { @@ -547,6 +553,36 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { }); } +/// The user templates folder (#2852): where File › New finds personal templates and Save as Word +/// Template starts. Empty is the app's own folder, shown as the hint. +fn templates_folder(app: &mut WordApp, ui: &mut Ui) { + let saved = app.session.prefs.user_templates_folder.clone().unwrap_or_default(); + let id = egui::Id::new("options_user_templates_folder"); + let mut folder = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| saved.clone()); + let default = app.services.templates_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); + ui.label(tl!("User templates:")); + ui.horizontal(|ui| { + let hint = default.clone().unwrap_or_else(|| tl!("Not set").to_string()); + let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(hint)); + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let changed = folder.trim() != saved; + if ui.add_enabled(changed, egui::Button::new(tl!("Apply"))).clicked() || (enter && changed) { + let value = if folder.trim().is_empty() { "auto" } else { folder.trim() }; + match app.run("file.options", json!({"userTemplatesFolder": value})) { + Ok(_) => folder = app.session.prefs.user_templates_folder.clone().unwrap_or_default(), + Err(e) => app.status(e), + } + } + if ui.add_enabled(!saved.is_empty(), egui::Button::new(tl!("Clear"))).clicked() + && app.run("file.options", json!({"userTemplatesFolder": "auto"})).is_ok() + { + folder.clear(); + } + }); + ui.data_mut(|d| d.insert_temp(id, folder)); + note(ui, "File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here."); +} + fn agents(ui: &mut Ui) { section(ui, "Agents"); ui.label(tl!("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel.")); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 273ca2d94..39859607a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,7 +52,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678)…) | | `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | diff --git a/docs/gaps.md b/docs/gaps.md index a356c5599..768a94e9c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (#8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -262,3 +262,4 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-12 | trivial | #21: .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 text lists as recipients; Filter and Sort in Edit Recipient List (#778) | | 2026-10-12 | trivial | #811: large-table scrolling fixed; performance gap (#20) narrowed | | 2026-10-12 | trivial | #13: radar, stock and other undrawn chart types show a placeholder instead of blank space (#998) | +| 2026-10-19 | trivial | Personal templates in File › New and Save as Word Template (#2852); no gap entry tracked it | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f0c86c021..44a68193b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,11 +2,11 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**495 of 510 catalog features have commands (97.1%).** +**496 of 511 catalog features have commands (97.1%).** | Tab | Live | Total | |---|---|---| -| File | 22 | 22 | +| File | 23 | 23 | | Quick Access | 5 | 5 | | Title Bar | 1 | 1 | | Home | 103 | 104 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fe71b73b4..ea703adef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -33,7 +33,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014) | ✅ | 75% | — | @@ -262,4 +262,5 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-13 | trivial | Double-clicking the top or bottom margin of a page with no header or footer creates the one that page shows and edits it, as in Word; one left empty is taken away again on leaving; `insert.editHeader`/`insert.editFooter {"page"}` for agents (#960); making one is an undo step (`insert.createHeader`/`insert.createFooter`), refused read-only, linked from the first section | | 2026-10-15 | trivial | Narrow windows: the tab strip keeps the room of Comments / Editing / Share and gives way in order (tab padding, Comments and Editing labels, Share label, plain tabs into », active and contextual labels cut with …, last the active tab into »); File never leaves the strip. Public sources say Word keeps those buttons in view and shrinks the tabs; the » menu is ours (#2026) | | 2026-10-15 | trivial | Interface Theme as a searchable View › Dark Mode command (light/dark/system, toggles light and dark without a value), driving the same setting as Options; ribbon menu rewired to it (#1861) | +| 2026-10-19 | trivial | Personal templates (#2852): File › New › Personal lists the user templates folder (`/Templates`, or File › Options › Advanced › File locations › User templates), subfolders as categories, searchable; a template opens as an untitled document attached to it; Save As › Save as Word Template starts there (`file.listTemplates`, `ui.saveAsTemplate`) | | 2026-10-15 | trivial | Double-clicking a page number in a later page's footer opens the footer there: the first click no longer moves the caret to the body text and scrolls the view up to it (#2103) |