mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 04:47:10 -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>
120 lines
4.7 KiB
Rust
120 lines
4.7 KiB
Rust
//! Range fields: a [`InlineObject::FieldStart`] … [`InlineObject::FieldEnd`] pair whose result is
|
|
//! the ordinary content between the two markers, possibly spanning paragraphs (a Zotero
|
|
//! bibliography). Pairs nest like brackets and never cross a block list: a pair opened in a
|
|
//! table cell closes in that cell.
|
|
//!
|
|
//! Editing can leave a marker without its partner (deleting a selection that holds only one
|
|
//! end). Such orphans are harmless in the model and in layout; writers call
|
|
//! [`Document::balance_field_ranges`] so a saved file always has whole fields.
|
|
|
|
use crate::para::OBJ;
|
|
use crate::{Block, Blocks, Document, InlineObject, Path, Pos, StoryRef};
|
|
|
|
/// Deepest table nesting scanned (matches `walk` in the crate root).
|
|
const MAX_DEPTH: usize = 16;
|
|
|
|
/// A whole range field.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct FieldRange {
|
|
pub instr: String,
|
|
pub locked: bool,
|
|
/// Where the `FieldStart` marker is.
|
|
pub start: Pos,
|
|
/// Where the `FieldEnd` marker is (the result is everything between the two).
|
|
pub end: Pos,
|
|
/// Number of enclosing range fields.
|
|
pub depth: usize,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Scan {
|
|
ranges: Vec<FieldRange>,
|
|
orphans: Vec<(Path, usize)>,
|
|
}
|
|
|
|
fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Scan) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
// Open starts: (index into out.ranges once closed is unknown, so keep the data here).
|
|
let mut open: Vec<(String, bool, Pos)> = Vec::new();
|
|
for (i, b) in bl.iter().enumerate() {
|
|
prefix.push(u32::try_from(i).unwrap_or(u32::MAX));
|
|
match &**b {
|
|
Block::Para(p) => {
|
|
let path = Path(prefix.clone());
|
|
for off in p.object_offsets() {
|
|
match p.object_at(off) {
|
|
Some(InlineObject::FieldStart { instr, locked }) => {
|
|
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
|
|
}
|
|
Some(InlineObject::FieldEnd) => match open.pop() {
|
|
Some((instr, locked, start)) => {
|
|
let depth = open.len();
|
|
out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth });
|
|
}
|
|
None => out.orphans.push((path.clone(), off)),
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
Block::Table(t) => {
|
|
for (r, row) in t.rows.iter().enumerate() {
|
|
for (c, cell) in row.cells.iter().enumerate() {
|
|
prefix.push(u32::try_from(r).unwrap_or(u32::MAX));
|
|
prefix.push(u32::try_from(c).unwrap_or(u32::MAX));
|
|
scan(story, &cell.blocks, prefix, depth + 1, out);
|
|
prefix.pop();
|
|
prefix.pop();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
prefix.pop();
|
|
}
|
|
out.orphans.extend(open.into_iter().map(|(_, _, p)| (p.path, p.off)));
|
|
}
|
|
|
|
fn scan_story(story: StoryRef, bl: &Blocks) -> Scan {
|
|
let mut out = Scan::default();
|
|
scan(story, bl, &mut Vec::new(), 0, &mut out);
|
|
out.ranges.sort_by(|a, b| a.start.path.cmp(&b.start.path).then(a.start.off.cmp(&b.start.off)));
|
|
out
|
|
}
|
|
|
|
impl Document {
|
|
fn story_refs(&self) -> Vec<StoryRef> {
|
|
std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k))).collect()
|
|
}
|
|
|
|
/// The whole range fields of a story, in document order of their starts.
|
|
pub fn field_ranges(&self, story: StoryRef) -> Vec<FieldRange> {
|
|
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
|
|
}
|
|
|
|
/// Whether any story holds a range-field marker without its partner.
|
|
pub fn has_unbalanced_field_ranges(&self) -> bool {
|
|
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
|
|
}
|
|
|
|
/// Remove range-field markers that have no partner. Returns how many were removed.
|
|
pub fn balance_field_ranges(&mut self) -> usize {
|
|
let mut removed = 0;
|
|
for s in self.story_refs() {
|
|
let Some(bl) = self.story(s) else { continue };
|
|
let mut orphans = scan_story(s, bl).orphans;
|
|
// Last first, so earlier offsets in the same paragraph stay valid.
|
|
orphans.sort();
|
|
for (path, off) in orphans.into_iter().rev() {
|
|
if let Ok(p) = self.para_mut(s, &path)
|
|
&& p.delete(off, off + OBJ.len_utf8()).is_ok()
|
|
{
|
|
removed += 1;
|
|
}
|
|
}
|
|
}
|
|
removed
|
|
}
|
|
}
|