mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
* Zotero 1: range fields and custom document properties round-trip Citation managers' ADDIN fields (Zotero, Mendeley, EndNote) are now kept as range fields: FieldStart/FieldEnd markers with the result as ordinary, formatted content between them, possibly spanning paragraphs (a Zotero bibliography). Before, the result was flattened to plain text and a multi-paragraph field lost its end. - doc: InlineObject::FieldStart/FieldEnd, Document::field_ranges, balance_field_ranges; CustomProp and custom_props with get/set/remove. - docx: read ADDIN fields as ranges (field codes up to 2 MiB), write them back as begin/instr/separate…end; orphan markers are dropped on save so files always hold whole fields. docProps/custom.xml is parsed and regenerated (simple types as text, vectors/blobs verbatim) instead of passed through, so Zotero's ZOTERO_PREF_n settings are editable. - engine: file.properties reads and sets custom properties. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 2: wire-protocol client and document bridge New crate wordcraft-zotero (L5) speaks Zotero's published word-processor protocol (port 23116; 32-bit transaction id + big-endian length frames, JSON payloads, ERR: replies): - client: sends an integration command (addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs) and answers Zotero's calls until Document_complete or the connection closes (cancelled dialog). Answers go through a callback so the app can apply them on its UI thread; run_on_session answers directly. - Bridge: implements Zotero's Document_*/Field_* calls on a Session. Fields are stored as Word's plugin stores them (ADDIN ZOTERO_ range fields; prefs in ZOTERO_PREF_n, with Zotero's ReferenceMark field type kept as Word's Field in the file), so documents stay Word-compatible. Rich text (RTF or HTML) keeps citation formatting; bibliographies get a Bibliography style from setBibliographyStyle; note styles create footnotes/endnotes, and deleting a lone note citation removes its note; placeholder links convert to fields. One transaction = one undo step. Field ids stay stable through inserts and deletes. - Tests: a fake Zotero over a real socket, every call, docx round trip, hostile arguments for every method, random frames (proptest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 3: Zotero in the app and the CLI, tested with the real Zotero - ui.zotero.<command> (addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs), ui.zotero.status and ui.zotero.answer. The socket runs on a background thread; each of Zotero's calls is answered on the UI thread against the session, and Zotero's questions become a dialog the session waits on. Web builds report that Zotero needs the desktop app. - wordcraft-cli zotero <command> <file> [--save] [--trace] [--port]. - Bridge: a selection Zotero makes to show a citation is put back when the session ends. - ui_shot: {"wait": ms} keeps frames running for background work. - docs/zotero.md. Checked against Zotero 10.0.6: refresh of a citation and bibliography (APA), its "modified citation" question answered in the app's dialog, RTF results with italics, Bibliography style, prefs round trip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 4: switching between in-text and note styles moves citations Document_convert and Field_convert now move a citation between the text, footnotes and endnotes, as Zotero asks when the document's style changes between an in-text and a note style: into a new note at the same place, or back into the text where the note mark was (removing a note that held only the citation; keeping one with other text). Field ids survive the move, formatting is kept, and each Zotero session stays one undo step. Checked against Zotero 10.0.6: unlink citations (removeCodes, with its confirmation question) and a refresh after switching the document to Chicago notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 5: Zotero tab in the ribbon A Zotero tab (before Help) with Add/Edit Citation, Add Note, Add/Edit Bibliography, Refresh, Document Preferences and Unlink Citations. The buttons are disabled while a Zotero command is running and in the web build. Three new icons drawn in code (document preferences, unlink, add note); the others reuse the References tab's. Labels and status messages translated (zh-hans, zh-hant, ja). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero: Add/Edit Bibliography inserted nothing Zotero reads a newly inserted bibliography field back before filling it, and unlinks a field whose text is empty, taking it for a bibliography the user deleted. Our new fields were empty, so Add/Edit Bibliography ended with Field_removeCode and nothing in the document. New fields now hold "{Citation}" until Zotero writes the result. Found by tracing the session with Zotero 10.0.6; regression test added. wordcraft-cli zotero takes --cmd (run before the Zotero command, e.g. caret.docEnd) to reproduce sessions from a given caret position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero: log each command's outcome and Zotero's calls to the app log Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cargo.lock: wordcraft-zotero at the workspace version (0.4.0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
105 lines
3.5 KiB
Rust
105 lines
3.5 KiB
Rust
//! Custom document properties (`docProps/custom.xml`, ECMA-376 Part 1 §22.3). Citation
|
|
//! managers keep their document preferences here (Zotero: `ZOTERO_PREF_1`…), so they must
|
|
//! survive a round trip intact.
|
|
|
|
use wordcraft_doc::CustomProp;
|
|
|
|
use crate::xml::{El, MAX_DEPTH, Node, W, esc};
|
|
|
|
/// The format identifier every custom property carries (§22.3.2.2).
|
|
const FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
|
|
/// Most properties we keep from one file.
|
|
const MAX_PROPS: usize = 10_000;
|
|
|
|
/// Simple variant types kept as text.
|
|
const SIMPLE: &[&str] = &[
|
|
"lpwstr", "lpstr", "bstr", "i1", "i2", "i4", "i8", "int", "ui1", "ui2", "ui4", "ui8", "uint", "r4", "r8", "decimal", "bool", "date", "filetime",
|
|
"cy", "error", "clsid",
|
|
];
|
|
|
|
/// Properties of a parsed `docProps/custom.xml` root.
|
|
pub fn read(root: &El) -> Vec<CustomProp> {
|
|
let mut out = Vec::new();
|
|
for p in root.els().filter(|e| e.local() == "property").take(MAX_PROPS) {
|
|
let Some(name) = p.attr("name").filter(|n| !n.is_empty()) else { continue };
|
|
let Some(v) = p.els().next() else { continue };
|
|
let kind = v.local();
|
|
let prop = if SIMPLE.contains(&kind) {
|
|
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() }
|
|
} else {
|
|
let mut xml = String::new();
|
|
el_xml(v, &mut xml, 0);
|
|
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml }
|
|
};
|
|
out.push(prop);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `docProps/custom.xml` for `props`.
|
|
pub fn write(props: &[CustomProp]) -> Vec<u8> {
|
|
let mut w = W::new();
|
|
w.open(
|
|
"Properties",
|
|
&[
|
|
("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"),
|
|
("xmlns:vt", "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"),
|
|
],
|
|
);
|
|
// Property ids start at 2 (§22.3.2.2).
|
|
for (pid, p) in (2u32..).zip(props.iter().filter(|p| !p.name.is_empty())) {
|
|
if p.kind == "raw" && !raw_is_wellformed(&p.value) {
|
|
continue;
|
|
}
|
|
w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]);
|
|
if p.kind == "raw" {
|
|
w.raw(&p.value);
|
|
} else {
|
|
let kind = if SIMPLE.contains(&p.kind.as_str()) { p.kind.as_str() } else { "lpwstr" };
|
|
w.leaf(&format!("vt:{kind}"), &[], &p.value);
|
|
}
|
|
w.close("property");
|
|
}
|
|
w.close("Properties");
|
|
w.into_bytes()
|
|
}
|
|
|
|
/// A raw value is written only if it parses inside a `vt`-namespaced wrapper.
|
|
fn raw_is_wellformed(xml: &str) -> bool {
|
|
let doc = format!(r#"<x xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">{xml}</x>"#);
|
|
crate::xml::parse(doc.as_bytes()).is_ok()
|
|
}
|
|
|
|
/// Serialise an element with its canonical prefixes.
|
|
fn el_xml(e: &El, out: &mut String, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
out.push('<');
|
|
out.push_str(&e.name);
|
|
for (k, v) in &e.attrs {
|
|
if k.starts_with("xmlns") {
|
|
continue;
|
|
}
|
|
out.push(' ');
|
|
out.push_str(k);
|
|
out.push_str("=\"");
|
|
out.push_str(&esc(v));
|
|
out.push('"');
|
|
}
|
|
if e.kids.is_empty() {
|
|
out.push_str("/>");
|
|
return;
|
|
}
|
|
out.push('>');
|
|
for k in &e.kids {
|
|
match k {
|
|
Node::El(c) => el_xml(c, out, depth + 1),
|
|
Node::Text(t) => out.push_str(&esc(t)),
|
|
}
|
|
}
|
|
out.push_str("</");
|
|
out.push_str(&e.name);
|
|
out.push('>');
|
|
}
|