Files
wordcraft/crates/zotero/src/fields.rs
T
43c0be5735 Zotero integration: cite while you write, with a Zotero tab (#189)
* 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>
2026-10-10 08:35:59 -04:00

138 lines
5.2 KiB
Rust

//! Zotero's fields and preferences as WordCraft stores them (the Word plugin's layout).
//!
//! A citation is a range field with code `ADDIN ZOTERO_ITEM CSL_CITATION {…}`; Zotero itself
//! sees the code without the `ADDIN ZOTERO_` prefix (`ITEM CSL_CITATION {…}`). Fields in
//! footnotes and endnotes are listed at their note reference, with the note's number.
use wordcraft_doc::para::{NoteKind, OBJ};
use wordcraft_doc::{Document, InlineObject, PartKind, Pos, StoryRef};
/// The stored code prefix.
pub const PREFIX: &str = "ADDIN ZOTERO_";
/// Custom property names holding the document preferences, numbered from 1.
pub const PREF_NAME: &str = "ZOTERO_PREF_";
/// Longest chunk per preference property (Word's custom property limit).
pub const PREF_CHUNK: usize = 255;
/// Most preference chunks read back.
const MAX_PREF_CHUNKS: usize = 10_000;
/// A Zotero field.
#[derive(Clone, Debug, PartialEq)]
pub struct ZField {
/// Position of the `FieldStart` marker.
pub start: Pos,
/// Position of the `FieldEnd` marker.
pub end: Pos,
/// Zotero's code (without the stored prefix).
pub code: String,
/// The note number for fields in a footnote or endnote, else 0.
pub note_index: u32,
}
impl ZField {
/// Where the result begins (just after the start marker).
pub fn content_start(&self) -> Pos {
Pos { off: self.start.off + OBJ.len_utf8(), ..self.start.clone() }
}
/// Just after the end marker.
pub fn after(&self) -> Pos {
Pos { off: self.end.off + OBJ.len_utf8(), ..self.end.clone() }
}
/// Whether `p` is inside the field (after its start marker, up to its end marker).
pub fn contains(&self, p: &Pos) -> bool {
p.story == self.start.story && *p >= self.content_start() && *p <= self.end
}
}
/// Zotero's code for a stored field code, if it is a Zotero field.
pub fn zotero_code(instr: &str) -> Option<&str> {
let t = instr.trim_start();
let head = t.get(..PREFIX.len())?;
head.eq_ignore_ascii_case(PREFIX).then(|| t.get(PREFIX.len()..).unwrap_or("").trim())
}
/// The stored field code for Zotero's code.
pub fn stored_code(code: &str) -> String {
format!("{PREFIX}{}", code.trim())
}
fn story_fields(doc: &Document, story: StoryRef, note_index: u32) -> Vec<ZField> {
doc.field_ranges(story)
.into_iter()
.filter_map(|r| zotero_code(&r.instr).map(|c| ZField { start: r.start, end: r.end, code: c.to_string(), note_index }))
.collect()
}
/// Every Zotero field in document order: the body's, with each note's fields at its reference.
pub fn list(doc: &Document) -> Vec<ZField> {
enum Ev {
Field(ZField),
Note(u32, u32),
}
let mut evs: Vec<(Pos, Ev)> = story_fields(doc, StoryRef::Body, 0).into_iter().map(|f| (f.start.clone(), Ev::Field(f))).collect();
let (mut foot, mut end) = (0u32, 0u32);
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
if let Some(InlineObject::NoteRef { kind, id, custom }) = p.object_at(off) {
let n = match kind {
NoteKind::Footnote => &mut foot,
NoteKind::Endnote => &mut end,
};
if custom.is_empty() {
*n = n.saturating_add(1);
}
evs.push((Pos::new(StoryRef::Body, path.clone(), off), Ev::Note(*id, (*n).max(1))));
}
}
}
evs.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = Vec::new();
for (_, ev) in evs {
match ev {
Ev::Field(f) => out.push(f),
Ev::Note(id, n) => out.extend(story_fields(doc, StoryRef::Part(id), n)),
}
}
out
}
/// Whether a citation can go at `p`: the body, a footnote or an endnote.
pub fn can_insert_at(doc: &Document, p: &Pos) -> bool {
match p.story {
StoryRef::Body => true,
StoryRef::Part(id) => doc.parts.get(&id).is_some_and(|part| matches!(part.kind, PartKind::Footnote | PartKind::Endnote)),
}
}
fn pref_index(name: &str) -> Option<usize> {
let head = name.get(..PREF_NAME.len())?;
if !head.eq_ignore_ascii_case(PREF_NAME) {
return None;
}
name.get(PREF_NAME.len()..)?.parse().ok()
}
/// The document preferences Zotero stored, or `""`.
pub fn get_prefs(doc: &Document) -> String {
let mut out = String::new();
for i in 1..=MAX_PREF_CHUNKS {
match doc.custom_prop(&format!("{PREF_NAME}{i}")) {
Some(v) => out.push_str(v),
None => break,
}
}
// Zotero sees the field type it knows this protocol by; the file keeps Word's.
out.replace(r#"name="fieldType" value="Field""#, r#"name="fieldType" value="ReferenceMark""#)
}
/// Store Zotero's document preferences in `ZOTERO_PREF_1…n`.
pub fn set_prefs(doc: &mut Document, data: &str) {
let data = data.replace(r#"name="fieldType" value="ReferenceMark""#, r#"name="fieldType" value="Field""#);
doc.custom_props.retain(|p| pref_index(&p.name).is_none());
let chars: Vec<char> = data.chars().collect();
for (i, chunk) in chars.chunks(PREF_CHUNK).take(MAX_PREF_CHUNKS).enumerate() {
doc.set_custom_prop(&format!("{PREF_NAME}{}", i + 1), &chunk.iter().collect::<String>());
}
}