Files
wordcraft/crates/engine/src/cmd/user_templates.rs
T
+5 462ecc8770 Engine: no dead-code warnings on wasm (#3542)
* fix(engine): do not treat decimal points as sentence boundaries in Sentence case

In crates/engine/src/cmd/format.rs, convert_chars treated any period as a sentence boundary, causing numbers with decimal points (e.g. 3.14, 2.5) to capitalize the subsequent word in Sentence case.

Check if a period is surrounded by ASCII digits, and do not trigger uppercase capitalization when encountering a decimal point.

Closes #3485

* proof: drop U+FE0F from FORBIDDENWORD and NOSUGGEST flags too

Review follow-up: the directives that name one flag read the same emoji
form, so they now skip the variation selector like PFX/SFX and stem flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt: rustfmt equation array centring

* #3108 review: behind-text and in-front-of-text floats don't grow the row

FredApps' review: the row-growth loop counted every non-inline object, so a
page-anchored logo or watermark behind a cell's text would stretch its row to
the object's bottom edge - contradicting table.rs (behind-text frames count
for vertical alignment but don't increase the row's flow height) and Word.
Only floats that take part in the text flow (Square, Tight, Through,
TopAndBottom) grow the row now, the same classification layout already uses
elsewhere for "takes part in wrapping".

New test: a 300pt behind-text and a 300pt in-front-of-text picture each leave
the row at its text height; it fails with the old condition. The existing
Square-wrap tests and cell_frame_centres_empty still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Engine: no dead-code warnings on wasm

`cargo check -p wordcraft-engine --target wasm32-unknown-unknown` warned
about five items whose only callers are platform-gated:
- citations `by_author`, user_templates `MAX_ENTRIES` and `label_of`: used
  only by the desktop (non-wasm) source import and template listing.
- speech `voice_language`: used by espeak (Unix but macOS) and the macOS
  voice picker, so `any(unix, test)`, not `any(not(windows), test)` (which
  also took in wasm).
- speech `primary_tag`: used by those plus the Windows synthesizer, so
  `any(unix, windows, test)`.

Each item now carries exactly its callers' gate rather than a blanket
allow(dead_code). Checked with cargo check on Linux, Windows (gnu), macOS
and wasm32 (zero warnings each), clippy -D warnings, and the engine tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Integration: #1847 reject comment IDs above u32::MAX

* Integration: #3267 leave footnote definitions out of link definitions

* Integration: #3471 drop duplicate egui options block; install_ui_fonts sets them

* Integration: #2153 drop trailing blank line and use assert! for bool checks in tab_separated_list_markers

* Integration: #2268 ODT lengths accept only the standard pc unit for picas

* Integration: #2269 LaTeX lengths keep macro names case-sensitive

* Integration: #2673 Khmer substitutes return the (list, flag) pair after #3245

* Integration: #2273 Latin weight/style and line styles take precedence over Asian, complex and line-type attributes

* Integration: #1851 DOCPROPERTY accepts the reference app's LastSavedBy name

* Integration: #1985 only ^0nnn codes read as Windows-1252

* Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if)

* Integration: #2185 hyperlink_target slices with get() and needs the whole field name

* Integration: #2034 objects without a stacking order draw above the ones before them

* Integration: #3434 drop review-bot notes from test doc comments

* Integration: #3419 doc comments back on their own items (EquationFrame and its test above them)

* Integration: #2334 test a formula string result continued in a CONTINUE record

* Integration: #3429 the based-on-chain test's doc comment back above its test

* Integration: #2453 drawing_payload test builds the shape fill as a Fill

* Integration: #3257 separate its Find-box test from #2407's recent-files test (auto-merge interleaved them)

* Integration: cargo fmt

* Integration: #1728 close the layout test the merge resolution left open

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: #2722 Dutch registry comment on its own line

* Integration: #3203 English text for the keys the Romanian catalog lacks

Its complete-catalog test needs every key of every other catalog; the merged PRs added 72 since it was written. Rows carry the English text until translated.

* Integration: #1076 English text for the keys the Slovenian catalog lacks

Its complete-catalog test needs every key of every other catalog. Rows carry the English text until translated.

* Integration: status lines list each change once

Combining the tracks' status lines repeated a few entries (#2852, #1500, #2617).

* Integration: cargo xtask parity

* Integration: cargo fmt

* Integration: #1832 drop a useless .into() in the end-of-document citation test

clippy::useless_conversion failed cargo xtask ci.

* Integration: #2244 VML on/off values read case-insensitively by the shape fill reader

Main (#1500) moved VML fill reading into shape_style.rs, where filled="False" still counted as on; #2244's test vml_shape_wrap_side_and_anchor_case_insensitivity expects no fill.

* Integration: #2041 English text in the Polish catalog for the keys Italian gained

polish_covers_every_key_of_every_other_catalog expects Italian to have only "Style: APA" beyond Polish; #2041 translated 14 newer strings into Italian. Rows carry the English text until translated.

* Integration: #2722 English text for four labels the Dutch and Danish catalogs lack

every_tab_and_command_is_translated needs Format Shape Pane, Full Screen View, Personal Templates and Underline Color, added by other merged PRs. Rows carry the English text until translated.

* Integration: #3335 header Esc test allows the half-pixel return scroll that #3201 brings

#3201 makes entering an empty header scroll to it; Esc then scrolls back to the caret, landing 0.5 px from the start (17291.0 vs 17290.5). The test checks the view is back within a pixel.

* IME: the caret follows the composition text (#3556)

* Header and footer: a click in the other band edits it there (#3557)

* README: the app icon above the title

* Release 0.10.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* #3542: gate the label_of test off wasm32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-23 10:25:42 -04:00

397 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
#[cfg(not(target_arch = "wasm32"))]
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.
#[cfg(not(target_arch = "wasm32"))]
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]
#[cfg(not(target_arch = "wasm32"))]
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"));
}
}