mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 02:22:13 +00: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>
138 lines
5.2 KiB
Rust
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>());
|
|
}
|
|
}
|