File › New: personal templates from a user templates folder (#2852) (#3133)

* #2852: personal templates in File › New, Save as Word Template

File › New lists the .dotx/.dotm files in the user templates folder under
Personal (a subfolder per category, search covers them); a click makes an
untitled document attached to the template. Save As › Save as Word Template
starts in that folder (<settings>/Templates, made on first use; File ›
Options › Advanced › File locations › User templates overrides it).
Engine: file.listTemplates (bounded, network folders refused, read root
applies), file.newFromTemplate sets the attached template, file.options
userTemplatesFolder, file.saveTemplate keeps a .dotm name. UI:
ui.saveAsTemplate; Save dialog requests carry a start folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

* #2852: review — a personal template asks about unsaved changes; network templates folder refused before it's looked at; New lists the folder once per visit with Refresh; .dotx/.dotm labels alike

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
This commit is contained in:
Martin Ball
2026-10-21 20:18:50 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 aiongg
parent dbebb2eca7
commit 5a6d2b6070
44 changed files with 1261 additions and 50 deletions
+1
View File
@@ -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
+2
View File
@@ -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());
+24 -8
View File
@@ -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<CommandSpec> {
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<Option<String>, 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<Option<String>, 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())));
+1 -1
View File
@@ -240,7 +240,7 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option<PathBuf> {
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'/')))
}
+6 -3
View File
@@ -489,17 +489,20 @@ pub fn specs() -> Vec<CommandSpec> {
.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}))
})
+393
View File
@@ -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<CommandSpec> {
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<String>,
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<String>,
}
/// What [`list_templates`] found.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Listing {
pub templates: Vec<PersonalTemplate>,
/// 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::<Vec<_>>(),
"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<Listing, CmdError> {
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<Listing, CmdError> {
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<crate::io::DirEntry>, Vec<std::path::PathBuf>, 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<String> {
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"));
}
}
+53
View File
@@ -561,6 +561,59 @@ pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, 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<u64>,
}
/// 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<DirEntry>, 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<String> {
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> {
+5
View File
@@ -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<String>,
/// 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<String>,
/// 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,