mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 13:27:27 -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>
95 lines
4.2 KiB
Rust
95 lines
4.2 KiB
Rust
//! Headless UI screenshots: renders the whole WordCraft window offscreen (wgpu), so it works
|
||
//! with a locked screen or a hidden window.
|
||
//!
|
||
//! `cargo run -p wordcraft-ui-egui --example ui_shot -- script.jsonl`
|
||
//!
|
||
//! The script is JSON lines: control-channel requests (`{"method": …, "params": …}`, see
|
||
//! `docs/control-protocol.md`), `{"shot": "/abs/out.png"}` to save the window as PNG, or
|
||
//! `{"steps": n}` to run extra frames, or `{"wait": ms}` to keep running frames for a while (for
|
||
//! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample
|
||
//! document unless the first line is `{"empty": true}`.
|
||
|
||
use wordcraft_engine::Session;
|
||
use wordcraft_ui_egui::{ControlRequest, Services, WordApp};
|
||
|
||
static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||
|
||
fn main() {
|
||
let path = std::env::args().nth(1).expect("usage: ui_shot script.jsonl");
|
||
let script = std::fs::read_to_string(&path).expect("read script");
|
||
let (tx, rx) = std::sync::mpsc::channel::<ControlRequest>();
|
||
let lines: Vec<serde_json::Value> =
|
||
script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect();
|
||
let doc = if lines.first().is_some_and(|l| l.get("empty").is_some()) {
|
||
wordcraft_doc::Document::new()
|
||
} else {
|
||
wordcraft_engine::sample::sample_document()
|
||
};
|
||
let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx);
|
||
app.integrated_titlebar = true;
|
||
let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32;
|
||
let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32;
|
||
let mut harness =
|
||
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
|
||
|ui, app: &mut WordApp| {
|
||
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
|
||
return;
|
||
}
|
||
let ctx = ui.ctx().clone();
|
||
app.logic(&ctx);
|
||
app.ui(ui);
|
||
},
|
||
app,
|
||
);
|
||
harness.input_mut().max_texture_side = Some(8192);
|
||
READY.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
step_n(&mut harness, 6);
|
||
for l in lines {
|
||
if let Some(p) = l.get("shot").and_then(|v| v.as_str()) {
|
||
step_n(&mut harness, 4);
|
||
match harness.render() {
|
||
Ok(img) => {
|
||
img.save(p).expect("save png");
|
||
println!("{p}");
|
||
}
|
||
Err(e) => eprintln!("render failed: {e}"),
|
||
}
|
||
} else if let Some(n) = l.get("steps").and_then(|v| v.as_u64()) {
|
||
step_n(&mut harness, n as usize);
|
||
} else if let Some(ms) = l.get("wait").and_then(|v| v.as_u64()) {
|
||
let end = std::time::Instant::now() + std::time::Duration::from_millis(ms);
|
||
while std::time::Instant::now() < end {
|
||
step(&mut harness);
|
||
std::thread::sleep(std::time::Duration::from_millis(16));
|
||
}
|
||
} else if let Some(m) = l.get("method").and_then(|v| v.as_str()) {
|
||
let (req, reply) = ControlRequest::new(m, l.get("params").cloned().unwrap_or_default());
|
||
tx.send(req).expect("send");
|
||
for _ in 0..60 {
|
||
step(&mut harness);
|
||
if let Ok(r) = reply.try_recv() {
|
||
let s = r.to_string();
|
||
// `UI_SHOT_FULL=1` prints whole replies (e.g. `document.inspect` for scripted checks).
|
||
let cut = if std::env::var_os("UI_SHOT_FULL").is_some() { s.len() } else { s.len().min(300) };
|
||
println!("{m}: {}", s.get(..cut).unwrap_or(&s));
|
||
break;
|
||
}
|
||
}
|
||
step_n(&mut harness, 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn step(harness: &mut egui_kittest::Harness<'_, WordApp>) {
|
||
let mut raw = std::mem::take(harness.input_mut());
|
||
harness.state_mut().raw_input_hook(&mut raw);
|
||
*harness.input_mut() = raw;
|
||
harness.step();
|
||
}
|
||
|
||
fn step_n(harness: &mut egui_kittest::Harness<'_, WordApp>, n: usize) {
|
||
for _ in 0..n {
|
||
step(harness);
|
||
}
|
||
}
|