diff --git a/ROADMAP.md b/ROADMAP.md index ef0e2222a..1ffb9e50f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,6 +158,9 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-22 | Restrict Editing: the CLI's `--author` uses the same protection check as `file.setAuthor`, and File › Options disables the User name field while the document is protected. | +| 2026-10-20 | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}`; the "none" mode is the shorthand and doesn't stop it), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting | +| 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones | | 2026-10-19 | Insert Table of Figures opens its dialog (#3013): pick the caption label (Figure, Table, Equation or your own), with or without label and number, page numbers and leader, so a document can have a list of figures and a list of tables. The list is a `TOC \c`/`\a` field (ECMA-376 §17.16.5.68); a Word table of figures with its switches in any order updates too, and Update Table keeps each list's label and leader. | | 2026-10-19 | Right-clicking text or pressing Shift+F10 offers the existing two-row formatting mini toolbar above the context-menu commands. Formatting keeps the selection and popup; menu commands, Escape and outside clicks dismiss it. This is the mini-toolbar portion of #2959; paste choices are in PR #2599. | | 2026-10-20 | The move and resize handles of a table show while the caret is in it, not only under the pointer, as in Word (#2428) | @@ -572,6 +575,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). | +| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting | +| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 78e4bbfa1..ef1a7be65 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -180,6 +180,8 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result { } /// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`). +/// The command also checks whether the open document is protected: this name determines which +/// named editing exceptions the session can use, so the flag must not bypass that check. fn set_author(s: &mut Session, name: &str) -> Result<(), String> { s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}")) } @@ -805,6 +807,31 @@ mod tests { assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok()); // The app's user name is the person's own; a bridge flag must not rewrite it. assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author"))); + // The flag goes through the same protection check as the command. + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.author.clone(); + assert!(set_author(&mut s, "Claude").is_err()); + assert_eq!(s.author, before); + assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err()); + } + + #[test] + fn author_flag_cannot_claim_a_named_exception_after_protection() { + use wordcraft_doc::{CharProps, InlineObject, StoryRef}; + + let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception.")); + let end = s.doc.end_of(StoryRef::Body); + s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); + let start = s.doc.start_of(StoryRef::Body); + s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap(); + assert_eq!(s.doc.perm_ranges().len(), 1); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + s.run("select.text", &json!({"text": "Named"})).unwrap(); + assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err()); + + assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception"); + assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err()); } #[test] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7b21994e2..98564e214 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -14,16 +14,24 @@ pub const FORMAT_FLAT_OPC: &str = "flatopc"; pub const MAX_AUTHOR_CHARS: usize = 255; -/// Sets the user name that new tracked changes and comments are recorded under. The name is -/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to -/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author. -pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { - let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); +/// The user name as it is stored: trimmed, stripped of control characters (they can't go into +/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always +/// carry an author. +pub fn normalize_author(name: &str) -> Result { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); let name = name.trim_end(); if name.is_empty() { return Err(CmdError::Params("`name` must not be blank".into())); } - s.author = name.to_string(); + Ok(name.to_string()) +} + +/// Sets the user name that new tracked changes and comments are recorded under. The name is +/// normalised by [`normalize_author`]. +/// A protected document refuses it: the name is the identity its exceptions are checked against. +pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { + super::protect::check_user_name(s)?; + s.author = normalize_author(p::req_str(v, "name")?)?; let mut r = sel_result(s)?; if let Some(o) = r.as_object_mut() { o.insert("name".into(), json!(s.author)); diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 8305e5ec7..61d57c292 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -47,6 +47,13 @@ pub fn enforced(doc: &Document) -> bool { doc.settings.protection.is_some() } +/// Is the document's editing restricted? The formatting-only protection stores the mode "none" +/// (`w:documentProtection w:edit="none"` with `w:formatting="1"`), so it doesn't count here: it +/// limits the styles, not the editing. +pub fn editing_restricted(doc: &Document) -> bool { + doc.settings.protection.as_deref().is_some_and(|m| m != "none") +} + /// May `author` edit inside the range? fn editable_by(r: &PermRange, author: &str) -> bool { r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim())) @@ -82,7 +89,14 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms"))); } let formatting = p::bool(v, "formatting"); - let stop = p::bool(v, "stop") == Some(true) || (mode == Some("none") && formatting != Some(true)); + let explicit_stop = p::bool(v, "stop") == Some(true); + // Stopping the protection is explicit: the "none" mode is the shorthand for it, but on a + // protected document only `{"stop": true}` stops it — a command that merely names a mode + // can't strip a user's protection (Word asks for Stop Protection first). + if mode == Some("none") && formatting != Some(true) && !explicit_stop && enforced(&s.doc) { + return Err(CmdError::Disabled("the document is protected: stop protection first (\"stop\": true)".into())); + } + let stop = explicit_stop || (mode == Some("none") && formatting != Some(true)); if stop { return stop_protection(s, p::str(v, "password").unwrap_or("")); } @@ -90,8 +104,16 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { // Only the locked styles changed (or nothing): report the state. return Ok(state(s)); } - if enforced(&s.doc) && !s.doc.settings.protect_hash.is_empty() { - return Err(CmdError::Disabled("the document is protected with a password: stop protection first".into())); + // The protection of a protected document doesn't change: Word asks for Stop Protection first. + // (The formatting-only protection isn't one: it limits the styles, not the editing — but a + // password on it still guards it, so it can't be replaced either.) + if editing_restricted(&s.doc) || !s.doc.settings.protect_hash.is_empty() { + let why = if s.doc.settings.protect_hash.is_empty() { + "the document is protected: stop protection first" + } else { + "the document is protected with a password: stop protection first" + }; + return Err(CmdError::Disabled(why.into())); } let formatting = formatting.unwrap_or(false); let mode = mode.unwrap_or("none"); @@ -202,9 +224,48 @@ const CONTROL_INSERTS: [&str; 9] = [ "developer.repeatingSection", ]; -/// Commands always allowed in a protected document: protection itself, file commands, undo. -fn always_allowed(id: &str) -> bool { - id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo" +/// Commands always allowed in a protected document: protection itself, undo, and the file +/// commands that don't change the open document. A `file.*` command that does change it is +/// checked like any other, so the protection holds for it too: the privacy options and the +/// properties are document settings, and the inspector removes content (#1327 follow-up). +fn always_allowed(id: &str, params: &Value) -> bool { + if id == "review.restrict" || id == "edit.undo" || id == "edit.redo" { + return true; + } + if !id.starts_with("file.") { + return false; + } + match id { + "file.privacyOptions" | "file.properties" => false, + // The user name says who may edit the document and who the revisions belong to: it is + // checked like any other change, not waved through. + "file.setAuthor" => false, + // The inspector reads the document; its removals change it. + "file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()), + _ => true, + } +} + +/// The user name (`file.setAuthor`) is the identity a named exception range is checked against, +/// and the author recorded on new revisions and comments: it can't change while the document is +/// protected. Word lets the user name change under protection (it is a preference there); this +/// project doesn't, because the name says who may edit what (#1327 follow-up). +pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> { + if enforced(&s.doc) { + return Err(CmdError::Disabled("file.setAuthor: stop protection before changing the user name".into())); + } + Ok(()) +} + +/// A version restore replaces the whole document, including the content the protection kept +/// read-only, so it needs the protection stopped first — and the password with it, when the +/// document has one. (`file.versions` is a pure command, so `check_before` doesn't see it; the +/// command checks this itself — see [`check_version_restore`].) +pub(crate) fn check_version_restore(s: &Session) -> Result<(), CmdError> { + if enforced(&s.doc) { + return Err(CmdError::Disabled("file.versions: stop protection before restoring a version".into())); + } + Ok(()) } /// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys @@ -229,9 +290,16 @@ pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> { /// [`outside_unchanged`]). pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result { let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) }; - if always_allowed(id) { + if always_allowed(id, params) { return Ok(false); } + // The user name says who may edit the document, and who the new revisions and comments + // belong to: it can't change while the document is protected. (`file.setAuthor` is a pure + // command, so `check_before` doesn't see it; the command checks this itself — see + // [`check_user_name`].) + if id == "file.setAuthor" { + check_user_name(s)?; + } // Word blocks content-control property edits until Stop Protection: changing a control's // lock flags would reshape what forms mode lets through. The param's presence is the // trigger — absent, the command keeps the current lock (as `check_formatting` inspects @@ -240,6 +308,23 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result