mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
* #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:
co-authored by
Claude Opus 5.5
aiongg
parent
dbebb2eca7
commit
5a6d2b6070
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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'/')))
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user