@xevos117/mcp-zotero 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +66 -12
  2. package/build/citation-injector/citation-formatter.js +3 -0
  3. package/build/citation-injector/citation-json.js +37 -0
  4. package/build/citation-injector/citekey-resolver.js +122 -0
  5. package/build/citation-injector/document-prefs.js +6 -3
  6. package/build/citation-injector/field-codes.js +2 -8
  7. package/build/citation-injector/formats/docx.js +83 -0
  8. package/build/citation-injector/formats/odt.js +149 -0
  9. package/build/citation-injector/formats/types.js +1 -0
  10. package/build/citation-injector/injector.js +100 -184
  11. package/build/citation-injector/model.js +9 -0
  12. package/build/citation-injector/odt-normalizer.js +169 -0
  13. package/build/citation-injector/pandoc-body.js +99 -0
  14. package/build/citation-injector/pipeline.js +189 -0
  15. package/build/citation-injector/zcite-normalizer.js +27 -11
  16. package/build/citation-injector/zcite.js +36 -0
  17. package/build/cited-document/create.js +40 -0
  18. package/build/cited-document/document.js +83 -0
  19. package/build/cited-document/markdown.js +104 -0
  20. package/build/cited-document/model.js +1 -0
  21. package/build/cited-document/number.js +17 -0
  22. package/build/cited-document/pandoc-cite.js +74 -0
  23. package/build/prompts/index.js +127 -0
  24. package/build/server.js +12 -5
  25. package/build/tools/add-items.js +9 -41
  26. package/build/tools/check-retractions.js +131 -0
  27. package/build/tools/create-cited-document.js +101 -0
  28. package/build/tools/create-note.js +77 -0
  29. package/build/tools/delete-collection.js +35 -22
  30. package/build/tools/delete-items.js +129 -42
  31. package/build/tools/find-duplicates.js +163 -0
  32. package/build/tools/get-collections.js +8 -18
  33. package/build/tools/get-items-details.js +11 -6
  34. package/build/tools/index.js +10 -0
  35. package/build/tools/inject-citations.js +29 -7
  36. package/build/tools/search-library.js +2 -2
  37. package/build/tools/update-item.js +123 -0
  38. package/build/utils/atomic-write.js +62 -0
  39. package/build/utils/crossref-retractions.js +76 -0
  40. package/build/utils/csl-to-zotero.js +69 -14
  41. package/build/utils/doi.js +55 -0
  42. package/build/utils/duplicates.js +64 -0
  43. package/build/utils/item-fields.js +58 -0
  44. package/build/utils/item-formatter.js +8 -0
  45. package/build/utils/unpaywall.js +1 -1
  46. package/package.json +11 -9
@@ -1,195 +1,111 @@
1
- import JSZip from "jszip";
2
- import { readFile, writeFile } from "node:fs/promises";
3
- import { format, parse } from "node:path";
4
1
  import { isZoteroApiError } from "../types/zotero-types.js";
5
- import { generateZoteroFieldCode, generateBibliographyFieldCode } from "./field-codes.js";
6
- import { formatCitationText } from "./citation-formatter.js";
7
- import { regexEscape, unescapeXml } from "./xml-utils.js";
8
- import { normalizeZciteTags } from "./zcite-normalizer.js";
9
- import { EXISTING_PREFS_WARNING, PREFS_NOT_WRITTEN_WARNING, writeZoteroDocumentPrefs } from "./document-prefs.js";
10
2
  import { zoteroItemToCsl } from "../utils/csl-to-zotero.js";
11
- import { ZOTERO_KEY } from "../utils/zotero-keys.js";
12
- const REGENERATE_DOCX = "The file is not a Word document or is corrupted: create it again as .docx (e.g. with the docx npm package) and call again.";
13
- const NO_ZCITE_WARNING = 'No <zcite> placeholders found: the output is an unchanged copy. Write them in the document body as <zcite keys="ITEMKEY"/> ' +
14
- "(headers, footers, footnotes and comments are not scanned).";
15
- function parseZciteMatches(documentXml) {
16
- const findRegex = /&lt;zcite\s+[\s\S]*?\/&gt;/g;
17
- const matches = [];
18
- let findMatch;
19
- while ((findMatch = findRegex.exec(documentXml)) !== null) {
20
- const fullMatch = findMatch[0];
21
- const cleanTag = unescapeXml(fullMatch);
22
- const attrRegex = /(\w+)="([^"]*)"/g;
23
- const attrs = {};
24
- let attrMatch;
25
- while ((attrMatch = attrRegex.exec(cleanTag)) !== null) {
26
- // Unescape attribute values since the zcite tag is XML
27
- // and values like "pp. 12 &amp; 15" need a second unescape
28
- attrs[attrMatch[1]] = unescapeXml(attrMatch[2]);
3
+ import { injectFile } from "./pipeline.js";
4
+ import { itemCitekeys } from "./citekey-resolver.js";
5
+ import { docxWriter } from "./formats/docx.js";
6
+ import { odtWriter } from "./formats/odt.js";
7
+ /** Scrittori per formato: Word (docx) e LibreOffice (odt). */
8
+ const WRITERS = { docx: docxWriter, odt: odtWriter };
9
+ /**
10
+ * ItemSource per il server: una GET per chiave dall'API Zotero; una chiave con 404 viene raccolta come
11
+ * assente dal chiamante, ogni altro errore interrompe subito (come prima in fetchCslData).
12
+ */
13
+ export function zoteroItemSource(zoteroApi, libraryType, userId) {
14
+ return async (keys) => {
15
+ const cslData = new Map();
16
+ for (const key of keys) {
17
+ let response;
18
+ try {
19
+ response = await zoteroApi.library(libraryType, userId).items(key).get();
20
+ }
21
+ catch (err) {
22
+ if (isZoteroApiError(err) && err.response?.status === 404)
23
+ continue;
24
+ throw err;
25
+ }
26
+ const zoteroItem = response.getData();
27
+ cslData.set(key, zoteroItemToCsl(zoteroItem));
29
28
  }
30
- // Tolerate "KEY1, KEY2": a stray space would otherwise become part of the key (404 on fetch)
31
- const keys = (attrs["keys"] ?? "").split(",").map((k) => k.trim()).filter(Boolean);
32
- if (keys.length === 0)
33
- continue;
34
- matches.push({
35
- fullMatch,
36
- keys,
37
- locator: attrs["locator"] || undefined,
38
- prefix: attrs["prefix"] || undefined,
39
- suffix: attrs["suffix"] || undefined,
40
- num: attrs["num"] || undefined,
41
- });
42
- }
43
- return matches;
29
+ return cslData;
30
+ };
44
31
  }
45
- async function fetchCslData(keys, zoteroApi, libraryType, userId) {
46
- const cslData = new Map();
47
- for (const key of keys) {
48
- let response;
49
- try {
50
- response = await zoteroApi
51
- .library(libraryType, userId)
52
- .items(key)
53
- .get();
54
- }
55
- catch (err) {
56
- // Unknown keys are collected and reported together by the caller; any other failure aborts at once
57
- if (isZoteroApiError(err) && err.response?.status === 404)
58
- continue;
59
- throw err;
32
+ /** Cap on the fallback scan, so resolving an unknown citekey never walks an unbounded library. */
33
+ const CITEKEY_SCAN_CAP = 5000;
34
+ /** Record an item's citekeys into the exact/similar sets of each requested citekey. */
35
+ function indexItemCitekeys(item, want) {
36
+ const key = typeof item.key === "string" ? item.key : undefined;
37
+ for (const ck of itemCitekeys(item)) {
38
+ const lc = ck.toLowerCase();
39
+ for (const [requested, w] of want) {
40
+ if (ck === requested) {
41
+ if (key)
42
+ w.exact.add(key);
43
+ }
44
+ else if (lc === w.lower) {
45
+ w.similar.add(ck);
46
+ }
60
47
  }
61
- const zoteroItem = response.getData();
62
- cslData.set(key, zoteroItemToCsl(zoteroItem));
63
48
  }
64
- return cslData;
65
49
  }
66
- function buildCitationItems(match, cslData, libraryType, userId) {
67
- return match.keys.map((key, idx) => {
68
- const itemData = cslData.get(key) ?? { type: "article-journal" };
69
- const item = {
70
- id: idx,
71
- uris: [`http://zotero.org/${libraryType}s/${userId}/items/${key}`],
72
- uri: [`http://zotero.org/${libraryType}s/${userId}/items/${key}`],
73
- itemData,
74
- };
75
- if (match.locator)
76
- item.locator = match.locator;
77
- if (match.prefix)
78
- item.prefix = match.prefix;
79
- if (match.suffix)
80
- item.suffix = match.suffix;
81
- return item;
82
- });
83
- }
84
- function replaceZciteInXml(xml, escapedZciteTag, fieldCodeXml) {
85
- // Case A (preferred): tag is the sole content of a <w:r>
86
- // Use [^<]*(?:<(?!/w:rPr>)[^<]*)* instead of .*? inside <w:rPr> to prevent
87
- // matching across element boundaries in minified (single-line) XML.
88
- const soloRunRegex = new RegExp(`<w:r>(?:<w:rPr>[^<]*(?:<(?!/w:rPr>)[^<]*)*</w:rPr>)?<w:t[^>]*>${regexEscape(escapedZciteTag)}</w:t></w:r>`);
89
- if (soloRunRegex.test(xml)) {
90
- return xml.replace(soloRunRegex, fieldCodeXml);
91
- }
92
- // Case B (fallback): tag is inline with other text
93
- const inlineRegex = new RegExp(`(<w:r>(?:<w:rPr>([^<]*(?:<(?!/w:rPr>)[^<]*)*)</w:rPr>)?<w:t[^>]*>)([^<]*)${regexEscape(escapedZciteTag)}([^<]*)(</w:t></w:r>)`);
94
- const inlineMatch = xml.match(inlineRegex);
95
- if (inlineMatch) {
96
- const rPr = inlineMatch[2]
97
- ? `<w:rPr>${inlineMatch[2]}</w:rPr>`
98
- : "";
99
- const textBefore = inlineMatch[3];
100
- const textAfter = inlineMatch[4];
101
- let replacement = "";
102
- if (textBefore) {
103
- replacement += `<w:r>${rPr}<w:t xml:space="preserve">${textBefore}</w:t></w:r>`;
50
+ /**
51
+ * CitekeyLookup per il server. Prima prova la ricerca `q=<citekey>&qmode=everything` (Extra compreso) e
52
+ * conferma lato client il match esatto (case-sensitive) sul campo `citationKey` o sulla riga
53
+ * `Citation Key:` di Extra. Se la ricerca non trova nessun match (l'indice full-text può non aver ancora
54
+ * indicizzato una voce appena creata, o `q` può non coprire Extra), ricade su una SCANSIONE paginata di
55
+ * `items/top` fino a CITEKEY_SCAN_CAP, che legge direttamente i dati delle voci (quindi vede subito anche
56
+ * una voce appena creata) e risolve in una sola passata tutti i citekey ancora mancanti. Un citekey che
57
+ * differisce solo per maiuscole/minuscole è raccolto come suggerimento per l'errore "not found".
58
+ */
59
+ export function zoteroCitekeyLookup(zoteroApi, libraryType, userId) {
60
+ return async (citekeys) => {
61
+ const state = new Map();
62
+ for (const ck of citekeys)
63
+ state.set(ck, { exact: new Set(), similar: new Set(), lower: ck.toLowerCase() });
64
+ // Pass 1: one search per citekey (qmode=everything includes full text, so page up to 5 pages).
65
+ for (const [citekey, s] of state) {
66
+ for (let page = 0; page < 5; page++) {
67
+ const response = await zoteroApi
68
+ .library(libraryType, userId)
69
+ .items()
70
+ .get({ q: citekey, qmode: "everything", limit: 100, start: page * 100 });
71
+ const items = response.getData() ?? [];
72
+ for (const it of items)
73
+ indexItemCitekeys(it, state);
74
+ if (s.exact.size > 0 || items.length < 100)
75
+ break;
76
+ }
104
77
  }
105
- replacement += fieldCodeXml;
106
- if (textAfter) {
107
- replacement += `<w:r>${rPr}<w:t xml:space="preserve">${textAfter}</w:t></w:r>`;
78
+ // Pass 2 (fallback): for the citekeys the search did not resolve, scan items/top directly (reads item
79
+ // data, not the search index), one page, until all are resolved or the cap is reached.
80
+ const unresolved = new Map([...state].filter(([, s]) => s.exact.size === 0));
81
+ if (unresolved.size > 0) {
82
+ let start = 0;
83
+ for (;;) {
84
+ const response = await zoteroApi.library(libraryType, userId).items().top().get({ limit: 100, start });
85
+ const page = response.getData() ?? [];
86
+ if (page.length === 0)
87
+ break;
88
+ for (const it of page)
89
+ indexItemCitekeys(it, unresolved);
90
+ start += page.length;
91
+ if ([...unresolved.values()].every((s) => s.exact.size > 0))
92
+ break;
93
+ if (start >= CITEKEY_SCAN_CAP || page.length < 100)
94
+ break;
95
+ }
108
96
  }
109
- return xml.replace(inlineRegex, replacement);
110
- }
111
- return xml;
97
+ const out = new Map();
98
+ for (const [ck, s] of state)
99
+ out.set(ck, { keys: [...s.exact], similar: [...s.similar] });
100
+ return out;
101
+ };
112
102
  }
113
- export async function injectCitations(filePath, zoteroApi, libraryType, userId, style) {
114
- // Derive "<name>_cited<ext>" from the basename only: a directory containing ".docx" stays untouched
115
- const { dir, name, ext } = parse(filePath);
116
- const outputPath = format({ dir, name: `${name}_cited`, ext });
117
- const fileBuffer = await readFile(filePath);
118
- let zip;
119
- try {
120
- zip = await JSZip.loadAsync(fileBuffer);
121
- }
122
- catch (err) {
123
- throw new Error(`Invalid .docx file: not a readable ZIP archive (${err instanceof Error ? err.message : String(err)}). ${REGENERATE_DOCX}`);
124
- }
125
- const documentEntry = zip.file("word/document.xml");
126
- if (!documentEntry) {
127
- throw new Error(`Invalid .docx file: word/document.xml not found. ${REGENERATE_DOCX}`);
128
- }
129
- let documentXml = await documentEntry.async("string");
130
- documentXml = normalizeZciteTags(documentXml);
131
- const matches = parseZciteMatches(documentXml);
132
- if (matches.length === 0) {
133
- const buffer = await zip.generateAsync({ type: "nodebuffer" });
134
- await writeFile(outputPath, buffer);
135
- return { outputPath, found: 0, injected: 0, warnings: [NO_ZCITE_WARNING] };
136
- }
137
- // Warn if using a numbered style but tags are missing the num attribute
138
- const warnings = [];
139
- if (style === "ieee" || style === "vancouver") {
140
- const withNum = matches.filter((m) => m.num !== undefined).length;
141
- if (withNum < matches.length) {
142
- warnings.push(`Style '${style}' requires 'num' attribute on <zcite> tags. Found ${withNum}/${matches.length} tags with num. ` +
143
- "Citations without num show [?] until the user runs Zotero → Refresh in Word; to get the numbers now, add " +
144
- 'num="N" (order of first citation) to every <zcite> in the original file and run again.');
145
- }
146
- }
147
- // Collect all unique item keys
148
- const uniqueKeys = new Set(matches.flatMap((m) => m.keys));
149
- // Keys from the document go into API URL paths (one GET per key) and Zotero URIs: only well-formed Zotero
150
- // keys, so a crafted <zcite> cannot point the request at another endpoint ("../", "?", "&"), and a key Zotero
151
- // would reject never reaches the document. The skill's inject.js applies the same check
152
- const invalid = [...uniqueKeys].filter((k) => !ZOTERO_KEY.test(k));
153
- if (invalid.length > 0) {
154
- throw new Error(`Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (a key is 8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ). ` +
155
- "No file was written. Use the keys returned by search_library, add_items_by_doi or add_items.");
156
- }
157
- // Fetch CSL data from Zotero
158
- const cslData = await fetchCslData(uniqueKeys, zoteroApi, libraryType, userId);
159
- // No placeholder citations in the user's document: a citation of a nonexistent item fails the injection,
160
- // like the skill's inject.js with a key missing from metadata.json
161
- const missingKeys = [...uniqueKeys].filter((k) => !cslData.has(k));
162
- if (missingKeys.length > 0) {
163
- throw new Error(`${missingKeys.length} cited key(s) not found in the library: ${missingKeys.join(", ")}. No file was written. ` +
164
- "Fix these keys in the document (find existing items with search_library, or add the missing ones with add_items_by_doi " +
165
- "or add_items) and call again; for a group library pass library_type and library_id.");
166
- }
167
- // Replace each zcite tag with a field code
168
- let injected = 0;
169
- for (const match of matches) {
170
- const citationItems = buildCitationItems(match, cslData, libraryType, userId);
171
- const itemDataList = match.keys.map((k) => cslData.get(k) ?? { type: "article-journal" });
172
- const formattedText = formatCitationText(itemDataList, style, match);
173
- const fieldCodeXml = generateZoteroFieldCode(citationItems, formattedText);
174
- const newXml = replaceZciteInXml(documentXml, match.fullMatch, fieldCodeXml);
175
- if (newXml !== documentXml) {
176
- documentXml = newXml;
177
- injected++;
178
- }
179
- }
180
- // Append bibliography before </w:body>
181
- const biblXml = generateBibliographyFieldCode();
182
- documentXml = documentXml.replace("</w:body>", `${biblXml}</w:body>`);
183
- // Document preferences (style, field type) where Zotero for Word reads them: no Document Preferences dialog
184
- // on the first Refresh, the injected style is applied (H-W1)
185
- zip.file("word/document.xml", documentXml);
186
- const prefs = await writeZoteroDocumentPrefs(zip, style);
187
- if (prefs === "kept")
188
- warnings.push(EXISTING_PREFS_WARNING);
189
- if (prefs === "skipped")
190
- warnings.push(PREFS_NOT_WRITTEN_WARNING);
191
- // Save
192
- const buffer = await zip.generateAsync({ type: "nodebuffer" });
193
- await writeFile(outputPath, buffer);
194
- return { outputPath, found: matches.length, injected, warnings };
103
+ /**
104
+ * Sostituisce i segnaposto <zcite> di un documento con le citazioni native di Zotero (field code Word per
105
+ * docx, reference mark LibreOffice per odt) e aggiunge la bibliografia. Involucro del nucleo comune
106
+ * (pipeline + scrittore di formato). `format` di default docx: il percorso Word non cambia. Se il
107
+ * documento non ha <zcite> ma marcatori Pandoc `[@…]`, usa il ramo pandoc (risoluzione dei citekey inclusa).
108
+ */
109
+ export async function injectCitations(filePath, zoteroApi, libraryType, userId, style, format = "docx") {
110
+ return injectFile(filePath, WRITERS[format] ?? docxWriter, zoteroItemSource(zoteroApi, libraryType, userId), { libraryType, userId, style }, zoteroCitekeyLookup(zoteroApi, libraryType, userId));
195
111
  }
@@ -0,0 +1,9 @@
1
+ /** Errore dell'iniezione con un codice stabile; il messaggio resta quello mostrato all'utente. */
2
+ export class InjectionError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "InjectionError";
8
+ }
9
+ }
@@ -0,0 +1,169 @@
1
+ import { XMLParser, XMLBuilder } from "fast-xml-parser";
2
+ /**
3
+ * Pre-elabora content.xml di un .odt per unire i tag <zcite> che LibreOffice ha spezzato tra più
4
+ * `text:span` (p.es. per un cambio di formato a metà tag), così parseZciteTags e lo scrittore ODT li
5
+ * trovano contigui. Analogo a zcite-normalizer.ts per Word.
6
+ *
7
+ * I commenti (office:annotation) e le note (text:note) sono esclusi come in Word: un <zcite> lì dentro
8
+ * non viene iniettato. Un `text:s text:c="n"` vale n spazi nell'estrazione del testo.
9
+ *
10
+ * Se non trova tag spezzati restituisce la stringa originale invariata (zero rischio di differenze di
11
+ * round-trip nell'XML).
12
+ */
13
+ const ZCITE_PATTERN = /&lt;zcite\s+[\s\S]*?\/&gt;/;
14
+ // A complete Pandoc bracketed citation inside one text node (no tag `<`); see zcite-normalizer.ts.
15
+ const PANDOC_PATTERN = /\[[^\]<]*@[^\]<]*\]/;
16
+ const PARSER_OPTIONS = {
17
+ preserveOrder: true,
18
+ ignoreAttributes: false,
19
+ processEntities: false,
20
+ trimValues: false,
21
+ parseTagValue: false,
22
+ parseAttributeValue: false,
23
+ };
24
+ const BUILDER_OPTIONS = {
25
+ preserveOrder: true,
26
+ ignoreAttributes: false,
27
+ processEntities: false,
28
+ format: false,
29
+ suppressEmptyNode: false,
30
+ suppressBooleanAttributes: false,
31
+ };
32
+ /** Sottoalberi da non toccare: commenti e note. */
33
+ const MASKED = new Set(["office:annotation", "text:note"]);
34
+ /** Testo visibile di un text:span: #text diretti più `text:s text:c=n` come n spazi. */
35
+ function extractSpanText(spanChildren) {
36
+ let text = "";
37
+ for (const child of spanChildren) {
38
+ if ("#text" in child)
39
+ text += String(child["#text"]);
40
+ else if ("text:s" in child) {
41
+ const c = Number(child[":@"]?.["@_text:c"] ?? "1");
42
+ text += " ".repeat(Number.isFinite(c) && c > 0 ? c : 1);
43
+ }
44
+ }
45
+ return text;
46
+ }
47
+ function isSpan(node) {
48
+ return "text:span" in node;
49
+ }
50
+ /** Un text:span che porta del testo (ha #text o text:s). */
51
+ function spanHasText(spanChildren) {
52
+ return spanChildren.some((c) => "#text" in c || "text:s" in c);
53
+ }
54
+ /** Gruppi di text:span consecutivi (con testo) figli diretti di un text:p/text:h. */
55
+ function collectConsecutiveSpanGroups(children) {
56
+ const groups = [];
57
+ let current = null;
58
+ const flush = () => {
59
+ if (current && current.texts.length >= 2)
60
+ groups.push(current);
61
+ current = null;
62
+ };
63
+ for (let i = 0; i < children.length; i++) {
64
+ const child = children[i];
65
+ if (isSpan(child) && spanHasText(child["text:span"])) {
66
+ const text = extractSpanText(child["text:span"]);
67
+ if (current) {
68
+ current.endIndex = i;
69
+ current.texts.push(text);
70
+ }
71
+ else {
72
+ current = { startIndex: i, endIndex: i, texts: [text] };
73
+ }
74
+ continue;
75
+ }
76
+ flush();
77
+ }
78
+ flush();
79
+ return groups;
80
+ }
81
+ /** Trova un <zcite> che attraversa più span del gruppo; ritorna l'intervalo [startOffset,endOffset]. */
82
+ function findSplitZcite(group, pattern) {
83
+ const { texts } = group;
84
+ for (let start = 0; start < texts.length; start++) {
85
+ let concat = "";
86
+ for (let end = start; end < texts.length; end++) {
87
+ concat += texts[end];
88
+ if (end <= start)
89
+ continue;
90
+ const m = pattern.exec(concat);
91
+ if (m) {
92
+ const matchStart = m.index;
93
+ const matchEnd = m.index + m[0].length;
94
+ const lastStart = concat.length - texts[end].length;
95
+ if (matchStart < lastStart && matchEnd > lastStart)
96
+ return { startOffset: start, endOffset: end };
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ /** Unisce gli span [absStart..absEnd] in uno solo (attributi del primo, testo concatenato come #text). */
103
+ function mergeSpansInPlace(children, group, startOffset, endOffset) {
104
+ const absStart = group.startIndex + startOffset;
105
+ const absEnd = group.startIndex + endOffset;
106
+ const count = absEnd - absStart + 1;
107
+ let merged = "";
108
+ for (let i = absStart; i <= absEnd; i++)
109
+ merged += extractSpanText(children[i]["text:span"]);
110
+ const newSpan = { "text:span": [{ "#text": merged }] };
111
+ const firstAttrs = children[absStart][":@"];
112
+ if (firstAttrs)
113
+ newSpan[":@"] = firstAttrs;
114
+ children.splice(absStart, count, newSpan);
115
+ }
116
+ function normalizeParagraph(children, pattern) {
117
+ let modified = false;
118
+ let changed = true;
119
+ while (changed) {
120
+ changed = false;
121
+ for (const group of collectConsecutiveSpanGroups(children)) {
122
+ const split = findSplitZcite(group, pattern);
123
+ if (split) {
124
+ mergeSpansInPlace(children, group, split.startOffset, split.endOffset);
125
+ modified = true;
126
+ changed = true;
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ return modified;
132
+ }
133
+ function walk(nodes, pattern) {
134
+ let modified = false;
135
+ for (const node of nodes) {
136
+ const tag = Object.keys(node).find((k) => k !== ":@" && k !== "#text");
137
+ if (tag && MASKED.has(tag))
138
+ continue; // non toccare commenti e note
139
+ if (tag === "text:p" || tag === "text:h") {
140
+ if (normalizeParagraph(node[tag], pattern))
141
+ modified = true;
142
+ }
143
+ for (const key of Object.keys(node)) {
144
+ if (key === ":@" || key === "#text")
145
+ continue;
146
+ const value = node[key];
147
+ if (Array.isArray(value) && walk(value, pattern))
148
+ modified = true;
149
+ }
150
+ }
151
+ return modified;
152
+ }
153
+ function normalizeOdt(contentXml, pattern, quick) {
154
+ if (!contentXml.includes(quick))
155
+ return contentXml;
156
+ const parsed = new XMLParser(PARSER_OPTIONS).parse(contentXml);
157
+ if (!walk(parsed, pattern))
158
+ return contentXml;
159
+ return new XMLBuilder(BUILDER_OPTIONS).build(parsed);
160
+ }
161
+ export function normalizeOdtZciteTags(contentXml) {
162
+ return normalizeOdt(contentXml, ZCITE_PATTERN, "zcite");
163
+ }
164
+ /** Merge Pandoc `[@…]` markers split across text:span in an ODT, mirror of normalizePandocMarkers. */
165
+ export function normalizeOdtPandocMarkers(contentXml) {
166
+ if (!contentXml.includes("[") || !contentXml.includes("@"))
167
+ return contentXml;
168
+ return normalizeOdt(contentXml, PANDOC_PATTERN, "[");
169
+ }
@@ -0,0 +1,99 @@
1
+ import { unescapeXml } from "./xml-utils.js";
2
+ /**
3
+ * Finds Pandoc-style citation markers in an already-normalized document body and turns them into neutral
4
+ * CitationRequest, for the Pandoc branch of inject_citations. Markers are read ONLY from visible text
5
+ * (docx `<w:t>` content, odt element content) — never from field codes (`<w:instrText>`), attributes or a
6
+ * reference mark's CSL JSON — so a second call on a `_cited` file does not treat a "@" inside an existing
7
+ * field (e.g. a title "Twitter: @handle") as a new citation.
8
+ *
9
+ * `raw` is the marker exactly as it appears in the body (XML-escaped), so the format writer's replaceTag
10
+ * can locate and replace it; `[` `]` `@` are never XML-escaped, only prose inside a marker may carry
11
+ * entities, which are unescaped before use.
12
+ *
13
+ * Bracketed forms — `[@KEY]`, `[@K1; @K2]`, `[see @KEY, p. 12]`, `-@KEY` — mark `inline: false`; a bare
14
+ * `@KEY` / `-@KEY` marks `inline: true`. An inline that does not resolve is later kept as text with a
15
+ * warning (not an error); an unknown bracketed key stays an error.
16
+ */
17
+ // Bracketed citekeys: Better BibTeX keys that begin and end on an alphanumeric/underscore, with internal
18
+ // punctuation only (so a trailing sentence "." or "," is not swallowed). Zotero keys are a strict subset.
19
+ const KEY = "[A-Za-z0-9_](?:[A-Za-z0-9_:.#$%&+?~/-]*[A-Za-z0-9_])?";
20
+ // Inline citekeys are conservative (no `/`, `#`, …) so paths and handles like @angular/core don't become
21
+ // one long key; whatever is captured and does not resolve is kept as text anyway.
22
+ const INLINE_KEY = "[A-Za-z0-9_](?:[A-Za-z0-9_:.-]*[A-Za-z0-9_])?";
23
+ // Inline marker: `@key` / `-@key` not preceded by a word char, `_`, `/` or `.` (so emails "a@b", paths
24
+ // "x/@y", "a.@b" and "foo_@bar" are not citations).
25
+ const INLINE_RE = new RegExp(`\\[([^\\]<]*@[^\\]<]*)\\]|(?<![A-Za-z0-9_/.])(-?)@(${INLINE_KEY})`, "g");
26
+ // Inside a bracket, a `@key` is a citation only at the start of a part or after whitespace/`;` — never after
27
+ // `/`, `_`, `.` or an alphanumeric (so a URL "http://x/@user" in a bracket is not a citation key).
28
+ const KEY_RE = new RegExp(`(^|[\\s;])(-?)@(${KEY})`);
29
+ const LOCATOR_RE = /^(?:p{1,2}\.|pp?\b|pages?\b|chap\.?|ch\.|sec\.?|§|fig\.?|no\.?|vol\.?|art\.?)/i;
30
+ /** Parse the already-unescaped inner text of a `[...]` marker into a request (one entry's prefix/locator/suffix). */
31
+ function parseBracketInner(inner) {
32
+ const parts = inner.split(";");
33
+ const keys = [];
34
+ let prefix;
35
+ let suffix;
36
+ let locator;
37
+ let suppressAuthor = false;
38
+ for (const part of parts) {
39
+ const mm = KEY_RE.exec(part);
40
+ if (!mm)
41
+ continue;
42
+ const at = mm.index + mm[1].length; // position of the (optional) "-" / "@"
43
+ keys.push(mm[3]);
44
+ if (parts.length === 1) {
45
+ const before = part.slice(0, at).trim();
46
+ if (before)
47
+ prefix = before;
48
+ suppressAuthor = mm[2] === "-";
49
+ const after = part.slice(at + mm[2].length + 1 + mm[3].length).replace(/^[.,;:]*\s*/, "").trim();
50
+ if (after) {
51
+ if (LOCATOR_RE.test(after))
52
+ locator = after;
53
+ else
54
+ suffix = after;
55
+ }
56
+ }
57
+ }
58
+ if (keys.length === 0)
59
+ return null;
60
+ return { keys, prefix, suffix, locator, suppressAuthor, inline: false };
61
+ }
62
+ /** Parse markers in a single visible-text string (XML-escaped), in order. */
63
+ export function parsePandocCitesInText(text) {
64
+ const requests = [];
65
+ const re = new RegExp(INLINE_RE.source, "g");
66
+ let m;
67
+ while ((m = re.exec(text)) !== null) {
68
+ if (m[1] !== undefined) {
69
+ const parsed = parseBracketInner(unescapeXml(m[1]));
70
+ if (parsed)
71
+ requests.push({ ...parsed, raw: m[0] });
72
+ }
73
+ else {
74
+ requests.push({ keys: [m[3]], suppressAuthor: m[2] === "-", inline: true, raw: m[0] });
75
+ }
76
+ }
77
+ return requests;
78
+ }
79
+ /** Visible-text segments of a body, in document order: `<w:t>` content for docx, element content for odt. */
80
+ function visibleTexts(body, format) {
81
+ const out = [];
82
+ // docx: only run text (<w:t>…</w:t>) — never <w:instrText> field codes. odt: element content between
83
+ // ">" and "<" — the CSL JSON of a reference mark lives in an attribute, so it is excluded.
84
+ const re = format === "docx" ? /<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g : />([^<]*)</g;
85
+ let m;
86
+ while ((m = re.exec(body)) !== null)
87
+ out.push(m[1]);
88
+ return out;
89
+ }
90
+ export function parsePandocCitesInBody(body, format) {
91
+ const requests = [];
92
+ for (const text of visibleTexts(body, format))
93
+ requests.push(...parsePandocCitesInText(text));
94
+ return requests;
95
+ }
96
+ /** Count of Pandoc markers in a body (for the mixed-document warning in the zcite branch). */
97
+ export function countPandocMarkers(body, format) {
98
+ return parsePandocCitesInBody(body, format).length;
99
+ }