Skip to content

feat: add inject_citations tool for Word .docx citation injection - #85

Open
mlenburg wants to merge 3 commits into
cookjohn:mainfrom
mlenburg:feature/inject-citations
Open

feat: add inject_citations tool for Word .docx citation injection#85
mlenburg wants to merge 3 commits into
cookjohn:mainfrom
mlenburg:feature/inject-citations

Conversation

@mlenburg

Copy link
Copy Markdown

Summary

Adds a new inject_citations MCP tool that replaces <zcite key="ITEMKEY"/> placeholders in a Word .docx file with native Zotero field codes (ADDIN ZOTERO_ITEM CSL_CITATION), producing a _cited.docx that the Zotero Word Plugin can refresh and reformat in any citation style.

This addresses all feedback from the initial PR review (except the zipSync threading issue, which is deferred per your guidance):

  • Entity unescape order: &amp; is now replaced last in parseZciteTag, preventing &amp;quot; from double-unescaping
  • Fallback removed: instead of silently embedding raw XML inside <w:t> (corrupt OOXML), the injector now throws a descriptive error if a placeholder cannot be matched within a valid <w:r> run
  • <w:r> attribute matching: all run patterns use <w:r[^>]*> to match Word's attribute-bearing runs (e.g. w:rsidR)
  • Path validation: requires absolute path; rejects _cited.docx as input; refuses to overwrite existing output without overwrite=true
  • Opt-in preference gate: enableCitationInjection pref with toggle in the Preferences UI; inject_citations is hidden from the tool list when disabled
  • uris[] populated: each citation item carries a zotero://select/library/items/<KEY> URI so Zotero relinks citations on refresh rather than treating them as unlinked/embedded
  • Randomised citation IDs: Math.random().toString(36) instead of sequential cit1, cit2… to avoid collisions with existing fields
  • Bibliography placement: inserted before <w:sectPr> (OOXML requires it as the last body child)

Tests

Pure functions extracted to citationInjector.pure.ts (no Gecko dependencies) and covered by 36 Mocha + Chai unit tests:

npm test

Tests cover parseZciteTag, formatCitationText, generateCitationFieldCode, validateDocxPath, xmlEscape, and the preprocessing regex (including the non-greedy fix and <w:r> attribute matching).

Note on zipSync

unzipSync/zipSync (fflate) still run synchronously on the main thread. For typical manuscript sizes this completes in well under a second. Async decompression is deferred as noted in your review.

Marc Lenburg and others added 3 commits June 13, 2026 17:47
Adds a new `inject_citations` MCP tool that replaces `<zcite key="ITEMKEY"/>`
placeholders in a Word .docx with native Zotero field codes (ADDIN ZOTERO_ITEM
CSL_CITATION), producing a `_cited.docx` that Zotero's Word Plugin can refresh
and reformat.

Addresses all feedback from PR review:
- Fix entity-unescape order in parseZciteTag (&amp; last to avoid double-unescape)
- Fail loudly on fallback instead of emitting invalid OOXML inside <w:t>
- Path validation: require absolute path, reject _cited.docx as input
- Overwrite protection: refuse to clobber existing output without overwrite=true
- Opt-in preference gate (enableCitationInjection) with UI toggle in Preferences
- Populate uris[] with zotero://select URIs so citations link on Zotero refresh
- Randomise citationID to avoid collisions with existing fields
- Bibliography inserted before <w:sectPr> (OOXML requires it as last body child)
- <w:r[^>]*> in all run patterns to match Word's attribute-bearing runs

Pure functions extracted to citationInjector.pure.ts (no Gecko deps) and tested
with 36 Mocha+Chai unit tests covering parseZciteTag, formatCitationText,
generateCitationFieldCode, validateDocxPath, xmlEscape, and the preprocessing
regex. Run with: npm test

Note: fflate (zip read/write) still runs synchronously on the main thread; async
decompression deferred per maintainer guidance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace `import { PathUtils } from "resource://gre/modules/PathUtils.sys.mjs"`
  with `declare const PathUtils: any` / `declare const IOUtils: any` — esbuild
  cannot resolve resource:// URLs at bundle time; they are ambient globals in
  the Zotero/Gecko runtime
- Cast response.body to string in callInjectCitations to satisfy strict TS
- Add generated i10n type entries for pref-citation-injection-* FTL keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Zotero.Prefs.get() without a default returns undefined for unset prefs,
causing !enabled to throw even when the toggle is visibly on in the UI.
The gate in streamableMCPServer.ts (which passes true as default) is
sufficient — remove the duplicate check from citationInjector.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cookjohn

cookjohn commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Thanks @mlenburg — this is a proper split and the core review items from #83 all check out: attribute-tolerant <w:r[^>]*> matching with a loud failure instead of the invalid-OOXML fallback, overwrite protection behind overwrite=true, absolute-path/docx/_cited validation, the opt-in enableCitationInjection gate (hidden from tools/list and rejected in tools/call when off), populated uris[], randomized citationIDs, and the &amp;-last unescape order. The bibliography-before-<w:sectPr> placement and the unit-test extraction are nice touches. zipSync stays deferred as agreed.

A few things before merge:

  1. package-lock.json was regenerated against an older package.json: its root deps read zotero-plugin-toolkit ^5.1.0-beta.4, zotero-types ^4.1.0-beta.1, zotero-plugin-scaffold ^0.8.0 while package.json in this same PR keeps ^5.1.2/^4.1.2/^0.8.6, esbuild is downgraded 0.28.0 → 0.25.8, and @napi-rs/canvas sneaks in. Please rebase on current main and regenerate the lockfile with only fflate added.
  2. Don't replace npm testzotero-plugin test is the integration-test entry point (fix(write): defer slow post-commit notifications #103's Zotero-runtime tests in test/ rely on it, and your tsconfig.test.json includes test/**/*.ts, which would try to Node-compile them). Note main now has a test:unit (mocha) script from fix(http): read request bodies as bytes to stop UTF-8 corruption #101 — please slot your unit test into that and scope the tsconfig include.
  3. libraryID handling: handleInjectCitations does parseInt(libraryIDStr, 10) || undefined, silently falling back to the personal library on invalid input. Please use the centralized resolveLibraryID (400 on non-integer, per Add optional libraryID support across MCP operations #50's convention) and switch the item lookup to Zotero.Items.getByLibraryAndKeyAsync — the sync variant can return false for unloaded group-library items and misreport them as not found.
  4. The tool description references list_libraries, which doesn't exist — it's get_libraries on main.
  5. uris format: zoteroItemUri() ignores its libraryID parameter and always emits zotero://select/library/items/<KEY>, which is wrong for group libraries. Zotero's Word integration matches citation URIs produced by Zotero.URI.getItemURI(item) (http://zotero.org/users/.../items/<KEY> form). Have you verified that a Zotero refresh in Word actually relinks citations with the zotero://select form? If not, please switch to Zotero.URI.getItemURI.
  6. Minor: the pref-citation-injection-* keys were only added to en-US/preferences.ftl — please add them to the other five locales (zh-CN/ja-JP/de-DE/fr-FR/es-ES; missing keys fall back to English, but the pane should be translated); consider having citationInjector.ts import the shared functions from citationInjector.pure.ts instead of duplicating them; and callInjectCitations currently returns {error} as a successful tool result on 4xx/5xx — throwing on status >= 400 would match how the other tools surface errors.

Once 1–4 are in I'm happy to merge; 5 can land here or as an immediate follow-up if you have refresh-test evidence. I'll close #83 now — #50 covered its group-library half and this PR is the citation half.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants