@xevos117/mcp-zotero 2.0.4 → 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 (45) 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 +1 -1
  30. package/build/tools/delete-items.js +96 -25
  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/doi.js +55 -0
  41. package/build/utils/duplicates.js +64 -0
  42. package/build/utils/item-fields.js +58 -0
  43. package/build/utils/item-formatter.js +8 -0
  44. package/build/utils/unpaywall.js +1 -1
  45. package/package.json +11 -9
@@ -0,0 +1,189 @@
1
+ import JSZip from "jszip";
2
+ import { readFile } from "node:fs/promises";
3
+ import { format, parse } from "node:path";
4
+ import { ZOTERO_KEY } from "../utils/zotero-keys.js";
5
+ import { writeFileAtomic } from "../utils/atomic-write.js";
6
+ import { InjectionError } from "./model.js";
7
+ import { parseZciteTags } from "./zcite.js";
8
+ import { parsePandocCitesInBody, countPandocMarkers } from "./pandoc-body.js";
9
+ import { resolveCitekeys } from "./citekey-resolver.js";
10
+ import { assignNumbers } from "../cited-document/number.js";
11
+ import { buildCitationItems } from "./citation-json.js";
12
+ import { formatCitationText } from "./citation-formatter.js";
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 mixedWarning(count) {
16
+ return (`The document also has ${count} Pandoc citation marker(s) ([@key]): they were left as text, because a document with ` +
17
+ "<zcite> placeholders is treated as a zcite document. To turn them into citations too, call inject_citations again " +
18
+ "on the output file.");
19
+ }
20
+ const EXISTING_BIBLIOGRAPHY_WARNING = "The document already has a Zotero bibliography: the new citations were added but no second bibliography was written " +
21
+ "(Zotero rebuilds the single bibliography on Refresh).";
22
+ function numberedWarning(style, withNum, total, app) {
23
+ return (`Style '${style}' requires 'num' attribute on <zcite> tags. Found ${withNum}/${total} tags with num. ` +
24
+ `Citations without num show [?] until the user runs Zotero → Refresh in ${app}; to get the numbers now, add ` +
25
+ 'num="N" (order of first citation) to every <zcite> in the original file and run again.');
26
+ }
27
+ /**
28
+ * Risolve le richieste di citazione: valida le chiavi (solo chiavi Zotero ben formate raggiungono l'API),
29
+ * recupera le voci dalla sorgente, verifica le chiavi mancanti e costruisce le citazioni con il testo
30
+ * provvisorio. Lancia InjectionError senza scrivere nulla se una chiave è invalida o assente.
31
+ */
32
+ export async function resolveCitations(requests, itemSource, opts) {
33
+ const uniqueKeys = new Set(requests.flatMap((r) => r.keys));
34
+ // Keys from the document go into API URL paths and Zotero URIs: only well-formed Zotero keys, so a
35
+ // crafted <zcite> cannot point the request at another endpoint, and a key Zotero would reject never
36
+ // reaches the document. The skill's inject.js applies the same check.
37
+ const invalid = [...uniqueKeys].filter((k) => !ZOTERO_KEY.test(k));
38
+ if (invalid.length > 0) {
39
+ throw new InjectionError("invalid_key", `Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (a key is 8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ). ` +
40
+ "No file was written. Use the keys returned by search_library, add_items_by_doi or add_items.");
41
+ }
42
+ const cslData = await itemSource(uniqueKeys);
43
+ // No placeholder citations: a citation of a nonexistent item fails the injection, like the skill's
44
+ // inject.js with a key missing from metadata.json.
45
+ const missingKeys = [...uniqueKeys].filter((k) => !cslData.has(k));
46
+ if (missingKeys.length > 0) {
47
+ throw new InjectionError("missing_keys", `${missingKeys.length} cited key(s) not found in the library: ${missingKeys.join(", ")}. No file was written. ` +
48
+ "Fix these keys in the document (find existing items with search_library, or add the missing ones with add_items_by_doi " +
49
+ "or add_items) and call again; for a group library pass library_type and library_id.");
50
+ }
51
+ return requests.map((request) => {
52
+ const items = buildCitationItems(request, cslData, opts.libraryType, opts.userId);
53
+ const itemData = request.keys.map((k) => cslData.get(k) ?? { type: "article-journal" });
54
+ const formattedText = formatCitationText(itemData, opts.style, request);
55
+ return { request, items, itemData, formattedText };
56
+ });
57
+ }
58
+ /**
59
+ * Inietta le citazioni nel pacchetto `zip` già caricato, con lo scrittore dato: normalizza il corpo, trova
60
+ * i segnaposto, li risolve e li sostituisce, aggiunge la bibliografia e scrive le preferenze del documento.
61
+ * Non scrive su disco (lo fa injectFile). Restituisce il numero di segnaposto trovati/iniettati e gli avvisi.
62
+ */
63
+ export async function injectPackage(zip, writer, itemSource, opts, citekeyLookup) {
64
+ const rawBody = await zip.file(writer.bodyPart).async("string");
65
+ // Internal recognition, no input flag. A document that contains <zcite> is a zcite document and takes
66
+ // exactly the path it does today (same code, same byte output); Pandoc markers in it, if any, are only
67
+ // reported. Otherwise, if it has Pandoc markers, the Pandoc branch runs. Neither: an unchanged copy.
68
+ if (rawBody.includes("zcite")) {
69
+ return injectZciteBranch(rawBody, zip, writer, itemSource, opts);
70
+ }
71
+ if (citekeyLookup) {
72
+ // The Pandoc branch itself returns an unchanged copy (found 0) if it finds no markers.
73
+ return injectPandocBranch(rawBody, zip, writer, itemSource, opts, citekeyLookup);
74
+ }
75
+ return { found: 0, injected: 0, warnings: [NO_ZCITE_WARNING] };
76
+ }
77
+ /** The zcite path, identical to before, plus a note when the document also carries Pandoc markers. */
78
+ async function injectZciteBranch(rawBody, zip, writer, itemSource, opts) {
79
+ let body = writer.normalize(rawBody);
80
+ const requests = parseZciteTags(body);
81
+ if (requests.length === 0) {
82
+ return { found: 0, injected: 0, warnings: [NO_ZCITE_WARNING] };
83
+ }
84
+ const warnings = [];
85
+ if (opts.style === "ieee" || opts.style === "vancouver") {
86
+ const withNum = requests.filter((r) => r.num !== undefined).length;
87
+ if (withNum < requests.length)
88
+ warnings.push(numberedWarning(opts.style, withNum, requests.length, writer.app));
89
+ }
90
+ const prepared = await resolveCitations(requests, itemSource, opts);
91
+ let injected = 0;
92
+ for (const p of prepared) {
93
+ const citationXml = writer.citationXml(p);
94
+ const newBody = writer.replaceTag(body, p.request.raw, citationXml);
95
+ if (newBody !== body) {
96
+ body = newBody;
97
+ injected++;
98
+ }
99
+ }
100
+ body = writer.appendBibliography(body);
101
+ zip.file(writer.bodyPart, body);
102
+ const prefs = await writer.writePrefs(zip, opts.style);
103
+ warnings.push(...writer.prefsWarnings(prefs));
104
+ // Mixed document: report the Pandoc markers left as text (counted on the unmodified body).
105
+ const pandoc = countPandocMarkers(rawBody, writer.format);
106
+ if (pandoc > 0)
107
+ warnings.push(mixedWarning(pandoc));
108
+ return { found: requests.length, injected, warnings };
109
+ }
110
+ /**
111
+ * The Pandoc path: merge split markers, parse `[@key]`/inline citations, resolve Better BibTeX citekeys to
112
+ * Zotero keys, then reuse the same resolve + writer machinery as zcite. If the document already has a
113
+ * Zotero bibliography (e.g. a second call on a once-injected file), no second one is written.
114
+ */
115
+ async function injectPandocBranch(rawBody, zip, writer, itemSource, opts, citekeyLookup) {
116
+ let body = normalizePandocBody(writer, rawBody);
117
+ const parsed = parsePandocCitesInBody(body, writer.format);
118
+ if (parsed.length === 0) {
119
+ return { found: 0, injected: 0, warnings: [NO_ZCITE_WARNING] };
120
+ }
121
+ // Resolve Better BibTeX citekeys. Unresolved inline markers are dropped (kept as text) with a warning;
122
+ // an unknown/ambiguous bracketed key throws, so nothing is written.
123
+ const { requests, warnings } = await resolveCitekeys(parsed, citekeyLookup);
124
+ if (requests.length === 0) {
125
+ // every marker was an inline that did not resolve → unchanged copy, like a document with no markers
126
+ warnings.push(NO_ZCITE_WARNING);
127
+ return { found: 0, injected: 0, warnings };
128
+ }
129
+ // Numbered styles: number by order of first citation (Pandoc markers carry no num attribute).
130
+ if (opts.style === "ieee" || opts.style === "vancouver") {
131
+ const nums = assignNumbers(requests);
132
+ for (const r of requests)
133
+ r.num = nums.get(r);
134
+ }
135
+ const prepared = await resolveCitations(requests, itemSource, opts);
136
+ let injected = 0;
137
+ // Replace longer markers first: an inline `@KEY` is a substring of `-@KEY` (and of a longer citekey), so
138
+ // replacing the shorter one first could match inside the longer. Length-descending removes that overlap;
139
+ // numbering was already assigned in document order above.
140
+ for (const p of [...prepared].sort((a, b) => b.request.raw.length - a.request.raw.length)) {
141
+ const citationXml = writer.citationXml(p);
142
+ const newBody = writer.replaceTag(body, p.request.raw, citationXml);
143
+ if (newBody !== body) {
144
+ body = newBody;
145
+ injected++;
146
+ }
147
+ }
148
+ // A once-injected document already has one bibliography; do not add a second (Zotero keeps one).
149
+ if (body.includes("ZOTERO_BIBL")) {
150
+ warnings.push(EXISTING_BIBLIOGRAPHY_WARNING);
151
+ }
152
+ else {
153
+ body = writer.appendBibliography(body);
154
+ }
155
+ zip.file(writer.bodyPart, body);
156
+ const prefs = await writer.writePrefs(zip, opts.style);
157
+ warnings.push(...writer.prefsWarnings(prefs));
158
+ return { found: requests.length, injected, warnings };
159
+ }
160
+ /** Pandoc run/span merge for the writer's format; falls back to the body unchanged for an unknown format. */
161
+ function normalizePandocBody(writer, body) {
162
+ return writer.normalizePandoc ? writer.normalizePandoc(body) : body;
163
+ }
164
+ /**
165
+ * Iniezione completa su un file: deriva "<nome>_cited<ext>" dal solo basename (una cartella che contiene
166
+ * ".docx" resta intatta), carica il pacchetto, inietta e salva l'output in modo atomico.
167
+ */
168
+ export async function injectFile(filePath, writer, itemSource, opts, citekeyLookup) {
169
+ const { dir, name, ext } = parse(filePath);
170
+ const outputPath = format({ dir, name: `${name}_cited`, ext });
171
+ const fileBuffer = await readFile(filePath);
172
+ let zip;
173
+ try {
174
+ zip = await JSZip.loadAsync(fileBuffer);
175
+ }
176
+ catch (err) {
177
+ throw new InjectionError("invalid_package", writer.invalidPackageMessage(err instanceof Error ? err.message : String(err)));
178
+ }
179
+ writer.sniff?.(zip); // mismatch di formato (docx vs odt): messaggio chiaro prima del controllo del corpo
180
+ if (!zip.file(writer.bodyPart)) {
181
+ throw new InjectionError("no_document", writer.bodyMissingMessage);
182
+ }
183
+ const { found, injected, warnings } = await injectPackage(zip, writer, itemSource, opts, citekeyLookup);
184
+ if (writer.finalize)
185
+ await writer.finalize(zip);
186
+ const buffer = await zip.generateAsync({ type: "nodebuffer", ...writer.generateOptions });
187
+ await writeFileAtomic(outputPath, buffer);
188
+ return { outputPath, found, injected, warnings };
189
+ }
@@ -2,6 +2,10 @@ import { XMLParser, XMLBuilder } from "fast-xml-parser";
2
2
  // Pattern to detect a complete zcite tag in entity-encoded text
3
3
  // (processEntities: false keeps &lt; etc. as literals)
4
4
  const ZCITE_PATTERN = /&lt;zcite\s+[\s\S]*?\/&gt;/;
5
+ // A complete Pandoc bracketed citation `[... @key ...]` within a single text node (no tag `<` inside).
6
+ // Used by the Pandoc branch of inject_citations to merge a marker Word split across runs. The zcite
7
+ // path keeps ZCITE_PATTERN, so its behaviour (and the golden Word output) is unchanged.
8
+ const PANDOC_PATTERN = /\[[^\]<]*@[^\]<]*\]/;
5
9
  const PARSER_OPTIONS = {
6
10
  preserveOrder: true,
7
11
  ignoreAttributes: false,
@@ -89,7 +93,7 @@ function collectConsecutiveRunGroups(children) {
89
93
  * Returns the sub-range [startOffset, endOffset] within the group (0-indexed),
90
94
  * or null if no split zcite found.
91
95
  */
92
- function findSplitZcite(group) {
96
+ function findSplitZcite(group, pattern) {
93
97
  const { texts } = group;
94
98
  // Sliding window: try all start positions
95
99
  for (let start = 0; start < texts.length; start++) {
@@ -99,7 +103,7 @@ function findSplitZcite(group) {
99
103
  // Only interested in splits (spanning 2+ runs)
100
104
  if (end <= start)
101
105
  continue;
102
- const m = ZCITE_PATTERN.exec(concat);
106
+ const m = pattern.exec(concat);
103
107
  if (m) {
104
108
  // Verify the match actually spans runs: the zcite must start before
105
109
  // the last run's text and end at or after the last run's start.
@@ -163,7 +167,7 @@ function mergeRunsInPlace(children, group, startOffset, endOffset) {
163
167
  * Scan a paragraph's children for split zcite tags and merge them.
164
168
  * Returns true if any modifications were made.
165
169
  */
166
- function normalizeParagraphRuns(children) {
170
+ function normalizeParagraphRuns(children, pattern) {
167
171
  let modified = false;
168
172
  // Use a while loop since indices shift after each merge
169
173
  let changed = true;
@@ -171,7 +175,7 @@ function normalizeParagraphRuns(children) {
171
175
  changed = false;
172
176
  const groups = collectConsecutiveRunGroups(children);
173
177
  for (const group of groups) {
174
- const split = findSplitZcite(group);
178
+ const split = findSplitZcite(group, pattern);
175
179
  if (split) {
176
180
  mergeRunsInPlace(children, group, split.startOffset, split.endOffset);
177
181
  modified = true;
@@ -186,12 +190,12 @@ function normalizeParagraphRuns(children) {
186
190
  * Recursively walk the parsed tree, finding w:p elements and normalizing
187
191
  * their runs.
188
192
  */
189
- function walkAndNormalize(nodes) {
193
+ function walkAndNormalize(nodes, pattern) {
190
194
  let modified = false;
191
195
  for (const node of nodes) {
192
196
  if ("w:p" in node) {
193
197
  const pChildren = node["w:p"];
194
- if (normalizeParagraphRuns(pChildren)) {
198
+ if (normalizeParagraphRuns(pChildren, pattern)) {
195
199
  modified = true;
196
200
  }
197
201
  }
@@ -201,7 +205,7 @@ function walkAndNormalize(nodes) {
201
205
  continue;
202
206
  const value = node[key];
203
207
  if (Array.isArray(value)) {
204
- if (walkAndNormalize(value)) {
208
+ if (walkAndNormalize(value, pattern)) {
205
209
  modified = true;
206
210
  }
207
211
  }
@@ -216,17 +220,29 @@ function walkAndNormalize(nodes) {
216
220
  * If no split zcites are found, returns the original string unchanged
217
221
  * (zero risk of round-trip encoding differences).
218
222
  */
219
- export function normalizeZciteTags(documentXml) {
220
- // Quick check: if no zcite at all, skip parsing entirely
221
- if (!documentXml.includes("zcite")) {
223
+ function normalizeMarkers(documentXml, pattern, quick) {
224
+ // Quick check: if the marker cannot be present, skip parsing entirely
225
+ if (!documentXml.includes(quick)) {
222
226
  return documentXml;
223
227
  }
224
228
  const parser = new XMLParser(PARSER_OPTIONS);
225
229
  const parsed = parser.parse(documentXml);
226
- const modified = walkAndNormalize(parsed);
230
+ const modified = walkAndNormalize(parsed, pattern);
227
231
  if (!modified) {
228
232
  return documentXml; // Return original string, no changes
229
233
  }
230
234
  const builder = new XMLBuilder(BUILDER_OPTIONS);
231
235
  return builder.build(parsed);
232
236
  }
237
+ export function normalizeZciteTags(documentXml) {
238
+ return normalizeMarkers(documentXml, ZCITE_PATTERN, "zcite");
239
+ }
240
+ /**
241
+ * Merge Pandoc `[@…]` citation markers that Word split across runs, so the whole bracket is one run and
242
+ * the Pandoc branch can find and replace it. The quick-check requires both `[` and `@` to be present.
243
+ */
244
+ export function normalizePandocMarkers(documentXml) {
245
+ if (!documentXml.includes("[") || !documentXml.includes("@"))
246
+ return documentXml;
247
+ return normalizeMarkers(documentXml, PANDOC_PATTERN, "[");
248
+ }
@@ -0,0 +1,36 @@
1
+ import { unescapeXml } from "./xml-utils.js";
2
+ /**
3
+ * Trova i tag <zcite .../> nel corpo del documento e li traduce in CitationRequest neutre. Lavora sul
4
+ * testo con l'escape XML (lo stesso in `w:t` di Word e in ODF), così è condiviso tra i formati.
5
+ *
6
+ * Tollera `keys="A, B"`: uno spazio di troppo diventerebbe parte della chiave (404 in fetch), quindi le
7
+ * chiavi vengono ripulite. I valori degli attributi hanno un secondo unescape perché il tag è a sua volta
8
+ * XML (p.es. un locator `pp. 12 &amp; 15`).
9
+ */
10
+ export function parseZciteTags(documentXml) {
11
+ const findRegex = /&lt;zcite\s+[\s\S]*?\/&gt;/g;
12
+ const requests = [];
13
+ let findMatch;
14
+ while ((findMatch = findRegex.exec(documentXml)) !== null) {
15
+ const fullMatch = findMatch[0];
16
+ const cleanTag = unescapeXml(fullMatch);
17
+ const attrRegex = /(\w+)="([^"]*)"/g;
18
+ const attrs = {};
19
+ let attrMatch;
20
+ while ((attrMatch = attrRegex.exec(cleanTag)) !== null) {
21
+ attrs[attrMatch[1]] = unescapeXml(attrMatch[2]);
22
+ }
23
+ const keys = (attrs["keys"] ?? "").split(",").map((k) => k.trim()).filter(Boolean);
24
+ if (keys.length === 0)
25
+ continue;
26
+ requests.push({
27
+ raw: fullMatch,
28
+ keys,
29
+ locator: attrs["locator"] || undefined,
30
+ prefix: attrs["prefix"] || undefined,
31
+ suffix: attrs["suffix"] || undefined,
32
+ num: attrs["num"] || undefined,
33
+ });
34
+ }
35
+ return requests;
36
+ }
@@ -0,0 +1,40 @@
1
+ import { resolveCitations } from "../citation-injector/pipeline.js";
2
+ import { zoteroItemSource } from "../citation-injector/injector.js";
3
+ import { docxWriter } from "../citation-injector/formats/docx.js";
4
+ import { odtWriter } from "../citation-injector/formats/odt.js";
5
+ import { writeFileAtomic } from "../utils/atomic-write.js";
6
+ import { parseMarkdown } from "./markdown.js";
7
+ import { assignNumbers } from "./number.js";
8
+ import { buildCitedDocument } from "./document.js";
9
+ const WRITERS = { docx: docxWriter, odt: odtWriter };
10
+ /**
11
+ * Crea un documento (.docx o .odt) da markdown con citazioni Zotero native. Riusa resolveCitations (stessi
12
+ * controlli ed errori di inject_citations), lo scheletro dello scrittore, le preferenze e la bibliografia.
13
+ * Non sovrascrive mai un file esistente (writeFileAtomic overwrite:false → EEXIST).
14
+ */
15
+ export async function createCitedDocument(markdown, outputPath, zoteroApi, libraryType, userId, style, format) {
16
+ const { blocks, warnings } = parseMarkdown(markdown);
17
+ // richieste di citazione in ordine di documento
18
+ const requests = [];
19
+ for (const b of blocks)
20
+ for (const i of b.inlines)
21
+ if (i.kind === "cite")
22
+ requests.push(i.request);
23
+ // numerazione per gli stili numerici
24
+ if (style === "ieee" || style === "vancouver") {
25
+ const nums = assignNumbers(requests);
26
+ for (const r of requests)
27
+ r.num = nums.get(r);
28
+ }
29
+ const prepared = await resolveCitations(requests, zoteroItemSource(zoteroApi, libraryType, userId), { libraryType, userId, style });
30
+ const byRequest = new Map(prepared.map((p) => [p.request, p]));
31
+ const resolvedBlocks = blocks.map((b) => ({
32
+ type: b.type,
33
+ level: b.level,
34
+ inlines: b.inlines.map((i) => (i.kind === "cite" ? { kind: "cite", prepared: byRequest.get(i.request) } : i)),
35
+ }));
36
+ const writer = WRITERS[format] ?? docxWriter;
37
+ const { buffer, warnings: buildWarnings } = await buildCitedDocument(writer, resolvedBlocks, style, requests.length > 0);
38
+ await writeFileAtomic(outputPath, buffer, { overwrite: false });
39
+ return { outputPath, blocks: blocks.length, citations: requests.length, warnings: [...warnings, ...buildWarnings] };
40
+ }
@@ -0,0 +1,83 @@
1
+ import { escapeXml } from "../citation-injector/xml-utils.js";
2
+ /**
3
+ * Costruisce il corpo del documento (da zero) a partire dai blocchi del markdown, inserendo le citazioni
4
+ * native con lo scrittore di formato. Grassetto/corsivo in linea, titoli, elenchi a un livello (resi come
5
+ * paragrafi con un punto elenco, per restare un pacchetto valido senza numbering/stili aggiuntivi).
6
+ */
7
+ // ─── docx ───────────────────────────────────────────────────────
8
+ function docxRun(text, { bold, italic }) {
9
+ const props = [];
10
+ if (bold)
11
+ props.push("<w:b/>");
12
+ if (italic)
13
+ props.push("<w:i/>");
14
+ const rPr = props.length ? `<w:rPr>${props.join("")}</w:rPr>` : "";
15
+ return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
16
+ }
17
+ function docxInlines(inlines, writer) {
18
+ return inlines.map((i) => (i.kind === "text" ? docxRun(i.text, { bold: i.bold, italic: i.italic }) : writer.citationXml(i.prepared))).join("");
19
+ }
20
+ export function renderDocxBlocks(blocks, writer) {
21
+ return blocks
22
+ .map((b) => {
23
+ // titoli con lo stile Heading<n> (outlineLvl): pannello di navigazione e sommario di Word
24
+ if (b.type === "heading")
25
+ return `<w:p><w:pPr><w:pStyle w:val="Heading${Math.min(b.level ?? 1, 6)}"/></w:pPr>${docxInlines(b.inlines, writer)}</w:p>`;
26
+ if (b.type === "list-item")
27
+ return `<w:p>${docxRun("•\t", {})}${docxInlines(b.inlines, writer)}</w:p>`;
28
+ return `<w:p>${docxInlines(b.inlines, writer)}</w:p>`;
29
+ })
30
+ .join("");
31
+ }
32
+ // ─── odt ────────────────────────────────────────────────────────
33
+ function odtInline(i, writer) {
34
+ if (i.kind === "cite")
35
+ return writer.citationXml(i.prepared);
36
+ const style = i.bold ? ' text:style-name="T_Bold"' : i.italic ? ' text:style-name="T_Italic"' : "";
37
+ return `<text:span${style}>${escapeXml(i.text)}</text:span>`;
38
+ }
39
+ export function renderOdtBlocks(blocks, writer) {
40
+ return blocks
41
+ .map((b) => {
42
+ const inner = b.inlines.map((i) => odtInline(i, writer)).join("");
43
+ if (b.type === "heading") {
44
+ const level = Math.min(b.level ?? 1, 6);
45
+ return `<text:h text:style-name="Heading_20_${level}" text:outline-level="${level}">${inner}</text:h>`;
46
+ }
47
+ if (b.type === "list-item")
48
+ return `<text:p>• ${inner}</text:p>`;
49
+ return `<text:p>${inner}</text:p>`;
50
+ })
51
+ .join("");
52
+ }
53
+ /**
54
+ * Assembla il documento: scheletro dello scrittore, corpo dai blocchi, bibliografia (solo se ci sono
55
+ * citazioni) e preferenze del documento. Non scrive su disco. `hasCitations` decide bibliografia/preferenze.
56
+ */
57
+ export async function buildCitedDocument(writer, blocks, style, hasCitations) {
58
+ if (!writer.skeleton)
59
+ throw new Error(`format ${writer.format} has no skeleton`);
60
+ const zip = await writer.skeleton();
61
+ const rendered = writer.format === "odt" ? renderOdtBlocks(blocks, writer) : renderDocxBlocks(blocks, writer);
62
+ let body = await zip.file(writer.bodyPart).async("string");
63
+ if (writer.format === "odt") {
64
+ body = body.replace("</office:text>", `${rendered}</office:text>`);
65
+ }
66
+ else {
67
+ body = body.includes("<w:sectPr") ? body.replace("<w:sectPr", `${rendered}<w:sectPr`) : body.replace("</w:body>", `${rendered}</w:body>`);
68
+ }
69
+ const warnings = [];
70
+ if (hasCitations) {
71
+ body = writer.appendBibliography(body);
72
+ zip.file(writer.bodyPart, body);
73
+ const prefs = await writer.writePrefs(zip, style);
74
+ warnings.push(...writer.prefsWarnings(prefs));
75
+ }
76
+ else {
77
+ zip.file(writer.bodyPart, body);
78
+ }
79
+ if (writer.finalize)
80
+ await writer.finalize(zip);
81
+ const buffer = (await zip.generateAsync({ type: "nodebuffer", ...writer.generateOptions }));
82
+ return { buffer, warnings };
83
+ }
@@ -0,0 +1,104 @@
1
+ import { parseCites } from "./pandoc-cite.js";
2
+ /**
3
+ * Sottoinsieme di markdown sostenuto da create_cited_document: titoli (#…), paragrafi, grassetto (**…**),
4
+ * corsivo (*…* o _…_), elenchi a un livello (- o *). Tutto il resto diventa testo, con un avviso. Le
5
+ * citazioni in stile pandoc nel testo vengono riconosciute da pandoc-cite.
6
+ */
7
+ /** Grassetto/corsivo, poi le citazioni dentro ciascun pezzo di testo (che ne eredita lo stile). */
8
+ function parseInlines(text, onWarning) {
9
+ const segments = [];
10
+ const re = /\*\*([^*]+)\*\*|\*([^*]+)\*|_([^_]+)_/g;
11
+ let last = 0;
12
+ let m;
13
+ while ((m = re.exec(text)) !== null) {
14
+ if (m.index > last)
15
+ segments.push({ text: text.slice(last, m.index) });
16
+ if (m[1] !== undefined)
17
+ segments.push({ text: m[1], bold: true });
18
+ else if (m[2] !== undefined)
19
+ segments.push({ text: m[2], italic: true });
20
+ else
21
+ segments.push({ text: m[3], italic: true });
22
+ last = m.index + m[0].length;
23
+ }
24
+ if (last < text.length)
25
+ segments.push({ text: text.slice(last) });
26
+ const out = [];
27
+ for (const s of segments) {
28
+ for (const inl of parseCites(s.text, onWarning)) {
29
+ if (inl.kind === "text") {
30
+ if (inl.text)
31
+ out.push({ kind: "text", text: inl.text, bold: s.bold, italic: s.italic });
32
+ }
33
+ else {
34
+ out.push(inl);
35
+ }
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ export function parseMarkdown(md) {
41
+ const blocks = [];
42
+ const warnings = new Set();
43
+ const warn = (m) => warnings.add(m);
44
+ const lines = md.replace(/\r\n?/g, "\n").split("\n");
45
+ let paragraph = [];
46
+ const flush = () => {
47
+ if (paragraph.length) {
48
+ blocks.push({ type: "paragraph", inlines: parseInlines(paragraph.join(" "), warn) });
49
+ paragraph = [];
50
+ }
51
+ };
52
+ let inFence = false;
53
+ for (const line of lines) {
54
+ if (/^\s*```/.test(line)) {
55
+ // blocco di codice: reso come testo
56
+ warnings.add("code blocks are rendered as plain text");
57
+ inFence = !inFence;
58
+ continue;
59
+ }
60
+ if (inFence) {
61
+ flush();
62
+ blocks.push({ type: "paragraph", inlines: [{ kind: "text", text: line }] });
63
+ continue;
64
+ }
65
+ if (line.trim() === "") {
66
+ flush();
67
+ continue;
68
+ }
69
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
70
+ if (heading) {
71
+ flush();
72
+ if (heading[1].length > 6)
73
+ warnings.add("heading levels deeper than 6 are rendered as level 6");
74
+ blocks.push({ type: "heading", level: Math.min(heading[1].length, 6), inlines: parseInlines(heading[2].trim(), warn) });
75
+ continue;
76
+ }
77
+ const item = /^(\s*)[-*+]\s+(.*)$/.exec(line);
78
+ if (item) {
79
+ flush();
80
+ if (item[1].length > 0)
81
+ warnings.add("nested lists are flattened to one level");
82
+ blocks.push({ type: "list-item", level: 1, inlines: parseInlines(item[2].trim(), warn) });
83
+ continue;
84
+ }
85
+ const ordered = /^\s*\d+\.\s+(.*)$/.exec(line);
86
+ if (ordered) {
87
+ flush();
88
+ warnings.add("ordered lists are rendered as bullet lists");
89
+ blocks.push({ type: "list-item", level: 1, inlines: parseInlines(ordered[1].trim(), warn) });
90
+ continue;
91
+ }
92
+ if (/^\s*>/.test(line))
93
+ warnings.add("block quotes are rendered as plain text");
94
+ if (/^\s*\|/.test(line))
95
+ warnings.add("tables are rendered as plain text");
96
+ if (/!\[[^\]]*\]\([^)]*\)/.test(line))
97
+ warnings.add("images are dropped; their alt text is kept");
98
+ paragraph.push(line.replace(/^\s*>\s?/, "").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1"));
99
+ }
100
+ flush();
101
+ if (blocks.length === 0)
102
+ warnings.add("the Markdown is empty; an empty document was created");
103
+ return { blocks, warnings: [...warnings] };
104
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Numera le citazioni per gli stili numerici (ieee/vancouver): ogni chiave prende il numero della sua
3
+ * prima comparsa nel testo; una chiave ripetuta mantiene lo stesso numero. Per una citazione con più voci
4
+ * il `num` è "n1,n2" nell'ordine delle chiavi. Restituisce la stringa `num` per ciascuna richiesta, così
5
+ * il testo provvisorio ([1], [1,2]) combacia con quello che Zotero scriverà al Refresh.
6
+ */
7
+ export function assignNumbers(requests) {
8
+ const order = new Map();
9
+ for (const r of requests)
10
+ for (const k of r.keys)
11
+ if (!order.has(k))
12
+ order.set(k, order.size + 1);
13
+ const out = new Map();
14
+ for (const r of requests)
15
+ out.set(r, r.keys.map((k) => order.get(k)).join(","));
16
+ return out;
17
+ }
@@ -0,0 +1,74 @@
1
+ import { ZOTERO_KEY } from "../utils/zotero-keys.js";
2
+ /**
3
+ * Riconosce le citazioni in stile pandoc dentro un testo e lo spezza in pezzi di testo e citazioni:
4
+ * [@K] una citazione
5
+ * [@K1; @K2] una citazione con due voci
6
+ * [see @K, p. 12] prefisso "see", locator "p. 12"
7
+ * -@K @K soppressione dell'autore / citazione in linea
8
+ * Le chiavi non vengono validate qui: resolveCitations rifiuta quelle non valide con lo stesso errore di
9
+ * inject_citations.
10
+ */
11
+ const LOCATOR_RE = /^(?:p{1,2}\.|pp?\b|pages?\b|chap\.?|ch\.|sec\.?|§|fig\.?|no\.?|vol\.?|art\.?)/i;
12
+ /** Una voce dentro le parentesi: `-?@KEY` con eventuale testo prima (prefix) e dopo (locator o suffix). */
13
+ function parseBracket(inner) {
14
+ const parts = inner.split(";");
15
+ const keys = [];
16
+ let prefix;
17
+ let suffix;
18
+ let locator;
19
+ let suppressAuthor = false;
20
+ for (const part of parts) {
21
+ const mm = /(-?)@([A-Za-z0-9]+)/.exec(part);
22
+ if (!mm)
23
+ continue;
24
+ keys.push(mm[2]);
25
+ if (parts.length === 1) {
26
+ const before = part.slice(0, mm.index).trim();
27
+ if (before)
28
+ prefix = before;
29
+ suppressAuthor = mm[1] === "-";
30
+ const after = part.slice(mm.index + mm[0].length).replace(/^,\s*/, "").trim();
31
+ if (after) {
32
+ if (LOCATOR_RE.test(after))
33
+ locator = after;
34
+ else
35
+ suffix = after;
36
+ }
37
+ }
38
+ }
39
+ if (keys.length === 0)
40
+ return null;
41
+ return { keys, prefix, suffix, locator, suppressAuthor, raw: `[${inner}]` };
42
+ }
43
+ export function parseCites(text, onWarning = () => { }) {
44
+ const out = [];
45
+ let last = 0;
46
+ // una citazione tra parentesi che contiene @, oppure una citazione in linea -@K / @K. Come pandoc, una
47
+ // citazione in linea comincia solo a inizio testo o dopo un carattere non alfanumerico: così "a@b.com"
48
+ // (una email) non viene scambiato per la citazione "@b".
49
+ const re = /\[([^\]]*@[^\]]*)\]|(?<![A-Za-z0-9])(-?)@([A-Za-z0-9]+)/g;
50
+ let m;
51
+ while ((m = re.exec(text)) !== null) {
52
+ if (m.index > last)
53
+ out.push({ kind: "text", text: text.slice(last, m.index) });
54
+ if (m[1] !== undefined) {
55
+ const req = parseBracket(m[1]);
56
+ if (req)
57
+ out.push({ kind: "cite", request: req });
58
+ else
59
+ out.push({ kind: "text", text: m[0] });
60
+ }
61
+ else if (ZOTERO_KEY.test(m[3])) {
62
+ out.push({ kind: "cite", request: { keys: [m[3]], suppressAuthor: m[2] === "-", raw: m[0] } });
63
+ }
64
+ else {
65
+ // una @parola in linea che non è una chiave Zotero ben formata resta testo, con un avviso (mai un errore)
66
+ out.push({ kind: "text", text: m[0] });
67
+ onWarning(`"${m[0]}" is not a well-formed Zotero item key (8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ); kept as text`);
68
+ }
69
+ last = m.index + m[0].length;
70
+ }
71
+ if (last < text.length)
72
+ out.push({ kind: "text", text: text.slice(last) });
73
+ return out;
74
+ }