mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 21:12:56 +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>
141 lines
4.8 KiB
Rust
141 lines
4.8 KiB
Rust
//! Running one integration command against Zotero over its socket.
|
|
//!
|
|
//! [`run_command`] connects, sends the command, then answers each of Zotero's calls through a
|
|
//! callback until `Document_complete` (or until Zotero closes the connection, which it does when
|
|
//! the user cancels). The callback form lets the app answer on its UI thread;
|
|
//! [`run_on_session`] answers directly against a session (CLI, tests).
|
|
|
|
use std::net::{SocketAddr, TcpStream};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::Value;
|
|
use wordcraft_engine::Session;
|
|
|
|
use crate::wire::{self, Call};
|
|
use crate::{Bridge, Host, ZoteroError};
|
|
|
|
/// Zotero's integration commands.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Command {
|
|
/// Add a citation, or edit the one at the caret.
|
|
AddEditCitation,
|
|
/// Add a bibliography, or edit the one at the caret.
|
|
AddEditBibliography,
|
|
/// Insert a Zotero note.
|
|
AddNote,
|
|
/// Update every citation and the bibliography.
|
|
Refresh,
|
|
/// Unlink citations: keep their text, drop the codes.
|
|
RemoveCodes,
|
|
/// Document preferences: citation style, language, note type.
|
|
SetDocPrefs,
|
|
}
|
|
|
|
impl Command {
|
|
pub const ALL: [Command; 6] =
|
|
[Command::AddEditCitation, Command::AddEditBibliography, Command::AddNote, Command::Refresh, Command::RemoveCodes, Command::SetDocPrefs];
|
|
|
|
/// The name on the wire.
|
|
pub fn wire_name(self) -> &'static str {
|
|
match self {
|
|
Command::AddEditCitation => "addEditCitation",
|
|
Command::AddEditBibliography => "addEditBibliography",
|
|
Command::AddNote => "addNote",
|
|
Command::Refresh => "refresh",
|
|
Command::RemoveCodes => "removeCodes",
|
|
Command::SetDocPrefs => "setDocPrefs",
|
|
}
|
|
}
|
|
|
|
/// By wire name (any case).
|
|
pub fn from_name(name: &str) -> Option<Command> {
|
|
Command::ALL.into_iter().find(|c| c.wire_name().eq_ignore_ascii_case(name))
|
|
}
|
|
}
|
|
|
|
/// Connection settings.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Options {
|
|
pub addr: SocketAddr,
|
|
/// How long to wait for Zotero to accept the connection.
|
|
pub connect_timeout: Duration,
|
|
/// How long to wait for Zotero's next call. Zotero's dialogs wait for the user, so this is
|
|
/// long; it only ends a session Zotero abandoned without closing the connection.
|
|
pub idle_timeout: Duration,
|
|
}
|
|
|
|
impl Default for Options {
|
|
fn default() -> Options {
|
|
Options {
|
|
addr: SocketAddr::from(([127, 0, 0, 1], wire::PORT)),
|
|
connect_timeout: Duration::from_secs(3),
|
|
idle_timeout: Duration::from_secs(60 * 60),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How a session ended.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Outcome {
|
|
/// Calls Zotero made.
|
|
pub calls: usize,
|
|
/// Zotero finished with `Document_complete` (false: it closed the connection, e.g. the
|
|
/// user cancelled its dialog).
|
|
pub completed: bool,
|
|
/// Calls we answered with an error.
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
/// Run `command`, answering every call with `answer`.
|
|
pub fn run_command(opts: &Options, command: Command, answer: &mut dyn FnMut(&Call) -> Result<Value, String>) -> Result<Outcome, ZoteroError> {
|
|
let mut stream = TcpStream::connect_timeout(&opts.addr, opts.connect_timeout).map_err(|e| ZoteroError::NotRunning(e.to_string()))?;
|
|
let _ = stream.set_nodelay(true);
|
|
stream.set_read_timeout(Some(opts.idle_timeout)).map_err(|e| ZoteroError::Io(e.to_string()))?;
|
|
wire::write_frame(&mut stream, 0, &wire::command_payload(command.wire_name()))?;
|
|
serve(&mut stream, answer)
|
|
}
|
|
|
|
/// Answer calls on an open connection until the session ends.
|
|
pub fn serve<S: std::io::Read + std::io::Write>(
|
|
stream: &mut S,
|
|
answer: &mut dyn FnMut(&Call) -> Result<Value, String>,
|
|
) -> Result<Outcome, ZoteroError> {
|
|
let mut out = Outcome::default();
|
|
while let Some(frame) = wire::read_frame(stream)? {
|
|
if frame.txid == 0 {
|
|
// Not a call; nothing to answer.
|
|
continue;
|
|
}
|
|
out.calls += 1;
|
|
let (reply, done) = match Call::parse(&frame.payload) {
|
|
Ok(call) => {
|
|
log::debug!("Zotero → {}", call.method);
|
|
let r = answer(&call);
|
|
(r, call.method == "Document_complete")
|
|
}
|
|
Err(e) => (Err(e), false),
|
|
};
|
|
if let Err(e) = &reply {
|
|
log::warn!("Zotero call failed: {e}");
|
|
out.errors.push(e.clone());
|
|
}
|
|
wire::write_frame(stream, frame.txid, &wire::encode_reply(&reply))?;
|
|
if done {
|
|
out.completed = true;
|
|
break;
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Run `command` against `session` directly.
|
|
pub fn run_on_session(
|
|
opts: &Options,
|
|
command: Command,
|
|
bridge: &mut Bridge,
|
|
session: &mut Session,
|
|
host: &mut dyn Host,
|
|
) -> Result<Outcome, ZoteroError> {
|
|
run_command(opts, command, &mut |call| bridge.handle(session, host, call))
|
|
}
|