mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
Merge PR #4217: Fields: SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS update with the others
This commit is contained in:
@@ -454,6 +454,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 |
|
||||
|
||||
@@ -975,10 +975,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)
|
||||
}
|
||||
@@ -1008,6 +1009,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
}
|
||||
let stories: Vec<StoryRef> =
|
||||
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::<Vec<_>>() {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
|
||||
@@ -1053,13 +1057,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 <name>: 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));
|
||||
}
|
||||
@@ -1072,6 +1087,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;
|
||||
}
|
||||
@@ -1130,6 +1148,45 @@ 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 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<String> {
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
@@ -1143,12 +1200,12 @@ fn doc_property_arg(instr: &str) -> Option<String> {
|
||||
|
||||
/// 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<String> {
|
||||
/// 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<String> {
|
||||
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(),
|
||||
@@ -1482,6 +1539,83 @@ 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<String> = 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<String> = 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]);
|
||||
}
|
||||
|
||||
/// 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<String> = 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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user