From eccb2fc230d022bd7141f27262bd6db67c8841ad Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:11:54 -0700 Subject: [PATCH 1/2] Fields: SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS update with the others Update Field left the SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS fields at their stored result, so one inserted in WordCraft showed nothing, and the Field dialog did not list them. These fields now update with the others and are listed under Document Information. A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces that property, at most 255 characters, as in Word, and later fields, DOCPROPERTY included, show the new value. NUMWORDS, NUMCHARS and REVNUM take a \* number format. --- ROADMAP.md | 1 + crates/engine/src/cmd/references.rs | 138 ++++++++++++++++++++++++--- crates/ui-egui/src/dialogs_insert.rs | 26 ++++- 3 files changed, 153 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c58a58439..08d965c07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -433,6 +433,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) | | 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders | | 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout | +| 2026-10-24 | Document information fields: SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS update with the other fields and are listed in the Field dialog; a quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces that document property (at most 255 characters), and NUMWORDS, NUMCHARS and REVNUM take a `\*` number format | | 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP | | 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off | | 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing | diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 4d312eff2..d1816a979 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -937,10 +937,11 @@ fn has_switch(instr: &str, sw: char) -> bool { }) } -/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR -/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's -/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only -/// when the caret is in the body. +/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, the document +/// information fields (TITLE, SUBJECT, AUTHOR, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM, NUMWORDS, +/// NUMCHARS, FILENAME, DOCPROPERTY) in the body, or in the header, footer, note or text box the +/// caret is in (the body's fields stay as they are then). SEQ numbering, citations and TOC are +/// body-only, so they run only when the caret is in the body. pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { update_fields_in(s, false) } @@ -970,6 +971,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { } let stories: Vec = if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] }; + // The document properties as the fields read and set them (`SUBJECT "New subject"`): a field + // after one that sets a property shows the new value. + let mut core = s.doc.core.clone(); for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::>() { let Some(p) = s.doc.para(story, &path) else { continue }; if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) { @@ -1015,13 +1019,24 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { updates.push((k, r)); } } - "TITLE" => updates.push((k, s.doc.core.title.clone())), - "AUTHOR" => updates.push((k, s.doc.core.creator.clone())), - "NUMWORDS" => updates.push((k, s.doc.word_count().to_string())), + // The summary properties; a quoted text after the name replaces the property. + "TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => { + if let Some(v) = new_property_value(instr) { + set_summary_property(&mut core, &name, v); + } + updates.push((k, summary_property(&core, &name).to_string())); + } + "LASTSAVEDBY" => updates.push((k, core.last_modified_by.clone())), + "NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))), + "NUMCHARS" => { + let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count(); + updates.push((k, count_text(instr, chars))); + } + "REVNUM" => updates.push((k, count_text(instr, core.revision as usize))), // DOCPROPERTY : a custom property first, then a built-in one (#1798). "DOCPROPERTY" => { if let Some(name) = doc_property_arg(instr) - && let Some(v) = doc_property(&s.doc, &name) + && let Some(v) = doc_property(&s.doc, &core, &name) { updates.push((k, v)); } @@ -1034,6 +1049,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { _ => {} } } + if core != s.doc.core { + s.doc.core.clone_from(&core); + } if updates.is_empty() { continue; } @@ -1092,6 +1110,40 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) { } } +/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows. +fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str { + match field { + "TITLE" => &core.title, + "SUBJECT" => &core.subject, + "AUTHOR" => &core.creator, + "KEYWORDS" => &core.keywords, + "COMMENTS" => &core.description, + _ => "", + } +} + +fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: String) { + match field { + "TITLE" => core.title = value, + "SUBJECT" => core.subject = value, + "AUTHOR" => core.creator = value, + "KEYWORDS" => core.keywords = value, + "COMMENTS" => core.description = value, + _ => {} + } +} + +/// The new value a summary-property field gives its property: the text after the field's name +/// (`SUBJECT "Quarterly report"`), at most 255 characters as Word allows. +fn new_property_value(instr: &str) -> Option { + FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect()) +} + +/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one. +fn count_text(instr: &str, n: usize) -> String { + wordcraft_layout::fields::format_switch(instr).unwrap_or(NumFormat::Decimal).format(u32::try_from(n).unwrap_or(u32::MAX)) +} + /// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name, /// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`). fn doc_property_arg(instr: &str) -> Option { @@ -1105,12 +1157,12 @@ fn doc_property_arg(instr: &str) -> Option { /// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property /// when one has this name, else the built-in property (English names, case-insensitively, as -/// Word's field accepts). `None` for a name this application doesn't know. -fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option { +/// Word's field accepts) from `core`. `None` for a name this application doesn't know. +fn doc_property(doc: &wordcraft_doc::Document, core: &wordcraft_doc::CoreProps, name: &str) -> Option { if let Some(v) = doc.custom_prop(name) { return Some(v.to_string()); } - let c = &doc.core; + let c = core; Some(match name.trim().to_ascii_lowercase().as_str() { "title" => c.title.clone(), "subject" => c.subject.clone(), @@ -1444,6 +1496,70 @@ mod tests { assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched"); } + /// A session whose body reads "one two three" and whose header holds stale fields with + /// these codes, the caret in the header. + fn header_fields_session(doc: wordcraft_doc::Document, instrs: &[&str]) -> (Session, u32) { + use wordcraft_doc::{CharProps, Paragraph, PartKind}; + let mut p = Paragraph::new(); + for instr in instrs { + let off = p.len(); + let field = + InlineObject::Field { instr: (*instr).into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() }; + p.insert_object(off, field, &CharProps::default()).unwrap(); + } + let mut doc = doc; + let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]); + let mut s = Session::new(doc); + let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0); + s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0)); + (s, hdr) + } + + /// SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY and REVNUM show the document properties, and + /// NUMCHARS counts the body's characters without spaces; the counts take a `\*` format. + #[test] + fn document_information_fields_show_the_properties() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.subject = "Budget".into(); + doc.core.keywords = "plan; costs".into(); + doc.core.description = "Draft for review".into(); + doc.core.last_modified_by = "Grace".into(); + doc.core.revision = 7; + let codes = ["SUBJECT", "KEYWORDS", "COMMENTS", "LASTSAVEDBY", "REVNUM", r"REVNUM \* ROMAN", "NUMCHARS", r"NUMWORDS \* roman"]; + let (mut s, hdr) = header_fields_session(doc, &codes); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]); + } + + /// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property + /// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value. + #[test] + fn summary_fields_with_a_new_value_replace_the_property() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.subject = "Old".into(); + let long = "x".repeat(300); + let long_code = format!("COMMENTS \"{long}\""); + let codes = [ + r#"SUBJECT "Quarterly report""#, + "SUBJECT", + r#"TITLE "Plan" \* MERGEFORMAT"#, + r#"AUTHOR "Ada""#, + r#"KEYWORDS "a, b""#, + &long_code, + "DOCPROPERTY Subject", + ]; + let (mut s, hdr) = header_fields_session(doc, &codes); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!(got[..5], ["Quarterly report", "Quarterly report", "Plan", "Ada", "a, b"]); + assert_eq!(got[5].chars().count(), 255); + assert_eq!(got[6], "Quarterly report", "DOCPROPERTY reads the subject the paragraph set"); + let c = &s.doc.core; + assert_eq!((c.subject.as_str(), c.title.as_str(), c.creator.as_str(), c.keywords.as_str()), ("Quarterly report", "Plan", "Ada", "a, b")); + assert_eq!(c.description, got[5]); + } + /// #1706: `all: true` (printing) updates the whole document even with the caret in a header. #[test] fn update_fields_all_covers_every_story() { diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index e467a9141..bb555b003 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -1039,9 +1039,15 @@ pub const FIELDS: &[(&str, &str, &str)] = &[ ("SAVEDATE", "Date and Time", "When this document was most recently saved"), ("TIME", "Date and Time", "The time right now"), ("AUTHOR", "Document Information", "Who the document properties name as author"), + ("COMMENTS", "Document Information", "Comments from the document properties"), ("FILENAME", "Document Information", "Name of the file this document is saved as"), + ("KEYWORDS", "Document Information", "Keywords from the document properties"), + ("LASTSAVEDBY", "Document Information", "Who last saved the document"), + ("NUMCHARS", "Document Information", "How many characters the document holds"), ("NUMPAGES", "Document Information", "How many pages the whole document has"), ("NUMWORDS", "Document Information", "How many words the document holds"), + ("REVNUM", "Document Information", "How many times the document has been saved"), + ("SUBJECT", "Document Information", "Subject from the document properties"), ("TITLE", "Document Information", "Title from the document properties"), ("PAGE", "Numbering", "Number of the page the field sits on"), ("SECTION", "Numbering", "Number of the section the field sits in"), @@ -1104,7 +1110,7 @@ pub const NUMBER_FORMATS: &[(&str, &str)] = &[ pub fn field_kind(name: &str) -> FieldKind { match name { "DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => FieldKind::Date, - "PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" => FieldKind::Number, + "PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" | "NUMCHARS" | "REVNUM" => FieldKind::Number, "REF" | "PAGEREF" => FieldKind::Bookmark, "SEQ" => FieldKind::Sequence, "MERGEFIELD" => FieldKind::Merge, @@ -1574,4 +1580,22 @@ mod tests { f.code = " ".into(); assert!(f.params().is_none()); } + + /// The Field dialog lists Word's document information fields, and the counts take a number + /// format. + #[test] + fn field_dialog_lists_the_document_information_fields() { + let listed: Vec<&str> = FIELDS.iter().filter(|(_, cat, _)| *cat == "Document Information").map(|(n, _, _)| *n).collect(); + for name in ["COMMENTS", "KEYWORDS", "LASTSAVEDBY", "NUMCHARS", "REVNUM", "SUBJECT"] { + assert!(listed.contains(&name), "{name} in {listed:?}"); + } + let a = app(); + let mut f = FieldForm::new(&a); + f.select("REVNUM"); + f.number_format = "ROMAN".into(); + f.preserve = false; + assert_eq!(f.instruction(), r"REVNUM \* ROMAN"); + f.select("SUBJECT"); + assert_eq!(f.instruction(), "SUBJECT"); + } } From 86ccf6cf3dc43782f4771c98006ecd0dcf69b1c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:55:11 -0700 Subject: [PATCH 2/2] Fields: only quoted text after a summary field's name replaces the property SUBJECT Draft left the field showing Draft and set the document's subject to it. A TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field now replaces its property only when quoted text follows the field's name, and otherwise shows the property as it is. --- crates/engine/src/cmd/references.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index d1816a979..764b36c8f 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -1133,9 +1133,14 @@ fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: } } -/// The new value a summary-property field gives its property: the text after the field's name -/// (`SUBJECT "Quarterly report"`), at most 255 characters as Word allows. +/// The new value a summary-property field gives its property: the quoted text after the field's +/// name (`SUBJECT "Quarterly report"`), at most 255 characters as Word allows. `None` when the +/// name is not followed by quoted text (`SUBJECT Draft`). fn new_property_value(instr: &str) -> Option { + let rest = instr.trim().split_once(char::is_whitespace)?.1.trim_start(); + if !rest.starts_with('"') { + return None; + } FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect()) } @@ -1560,6 +1565,19 @@ mod tests { assert_eq!(c.description, got[5]); } + /// Text after the name that is not quoted leaves the property as it is. + #[test] + fn summary_fields_without_quotes_keep_the_property() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.subject = "Old".into(); + doc.core.creator = "Grace".into(); + let (mut s, hdr) = header_fields_session(doc, &["SUBJECT Draft", "AUTHOR Ada", "DOCPROPERTY Subject"]); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!(got, ["Old", "Grace", "Old"]); + assert_eq!((s.doc.core.subject.as_str(), s.doc.core.creator.as_str()), ("Old", "Grace")); + } + /// #1706: `all: true` (printing) updates the whole document even with the caret in a header. #[test] fn update_fields_all_covers_every_story() {