@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.
- package/README.md +66 -12
- package/build/citation-injector/citation-formatter.js +3 -0
- package/build/citation-injector/citation-json.js +37 -0
- package/build/citation-injector/citekey-resolver.js +122 -0
- package/build/citation-injector/document-prefs.js +6 -3
- package/build/citation-injector/field-codes.js +2 -8
- package/build/citation-injector/formats/docx.js +83 -0
- package/build/citation-injector/formats/odt.js +149 -0
- package/build/citation-injector/formats/types.js +1 -0
- package/build/citation-injector/injector.js +100 -184
- package/build/citation-injector/model.js +9 -0
- package/build/citation-injector/odt-normalizer.js +169 -0
- package/build/citation-injector/pandoc-body.js +99 -0
- package/build/citation-injector/pipeline.js +189 -0
- package/build/citation-injector/zcite-normalizer.js +27 -11
- package/build/citation-injector/zcite.js +36 -0
- package/build/cited-document/create.js +40 -0
- package/build/cited-document/document.js +83 -0
- package/build/cited-document/markdown.js +104 -0
- package/build/cited-document/model.js +1 -0
- package/build/cited-document/number.js +17 -0
- package/build/cited-document/pandoc-cite.js +74 -0
- package/build/prompts/index.js +127 -0
- package/build/server.js +12 -5
- package/build/tools/add-items.js +9 -41
- package/build/tools/check-retractions.js +131 -0
- package/build/tools/create-cited-document.js +101 -0
- package/build/tools/create-note.js +77 -0
- package/build/tools/delete-collection.js +1 -1
- package/build/tools/delete-items.js +96 -25
- package/build/tools/find-duplicates.js +163 -0
- package/build/tools/get-collections.js +8 -18
- package/build/tools/get-items-details.js +11 -6
- package/build/tools/index.js +10 -0
- package/build/tools/inject-citations.js +29 -7
- package/build/tools/search-library.js +2 -2
- package/build/tools/update-item.js +123 -0
- package/build/utils/atomic-write.js +62 -0
- package/build/utils/crossref-retractions.js +76 -0
- package/build/utils/doi.js +55 -0
- package/build/utils/duplicates.js +64 -0
- package/build/utils/item-fields.js +58 -0
- package/build/utils/item-formatter.js +8 -0
- package/build/utils/unpaywall.js +1 -1
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -2,10 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
> **Note:** This is an unofficial community project and is not affiliated with, endorsed by, or supported by the Zotero team or the Corporation for Digital Scholarship. "Zotero" is a registered trademark of the Corporation for Digital Scholarship.
|
|
4
4
|
|
|
5
|
-
A Model Context Protocol server for Zotero integration. It gives any LLM full access to your Zotero library: search, organize, add papers by DOI, import PDFs, read full-text content, and
|
|
5
|
+
A Model Context Protocol server for Zotero integration. It gives any LLM full access to your Zotero library: search, organize, add papers by DOI, import PDFs, read full-text content, edit items and notes, check a bibliography for retractions and duplicates, and put live Zotero citations into Word and LibreOffice documents.
|
|
6
6
|
|
|
7
7
|
> Originally based on [mcp-zotero](https://github.com/kaliaboi/mcp-zotero) by Abhishek Kalia.
|
|
8
|
-
> This project has since been extensively rewritten with a new architecture,
|
|
8
|
+
> This project has since been extensively rewritten with a new architecture, 20 tools (up from 5), citation injection, PDF management, and Claude skill support.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Live citations in Word and LibreOffice.** `inject_citations` turns placeholders in an existing `.docx` or `.odt` into native Zotero citations and a bibliography, which the Zotero plugin of either app can refresh and restyle. Placeholders can be `<zcite>` tags or Pandoc markers (`[@KEY]`), with Zotero item keys or Better BibTeX citation keys ([details](#citation-placeholders)).
|
|
13
|
+
- **Cited documents from Markdown.** `create_cited_document` writes a new `.docx` or `.odt` from Markdown with Pandoc-style citations, real heading styles and a bibliography.
|
|
14
|
+
- **Five citation styles:** APA, IEEE, Vancouver, Harvard and Chicago.
|
|
15
|
+
- **Adding references** by DOI or with full metadata (all 37 Zotero item types), with open-access PDFs attached automatically through Unpaywall.
|
|
16
|
+
- **Editing:** `update_item` changes fields and creators with a version check (never overwrites a concurrent edit); `create_note` attaches a plain-text note to an item.
|
|
17
|
+
- **Checking a bibliography:** `check_retractions` flags retracted papers, expressions of concern and corrections (Crossref, with Retraction Watch data); `find_duplicates` finds items with the same DOI or title, or tells which DOIs are already in the library before importing them.
|
|
18
|
+
- **Safe deletion:** `delete_items` moves items to the Zotero trash by default; permanent deletion needs `permanent: true`, and every deletion is off unless `UNSAFE_OPERATIONS` allows it.
|
|
19
|
+
- **Group libraries,** set for the whole server or per call.
|
|
20
|
+
- **MCP prompts** for common workflows: citing a document, writing a cited document, importing references, summarizing an item into a note, checking a bibliography before submission.
|
|
21
|
+
- **Claude skill** for clients without filesystem access (Claude.ai Projects, Claude Desktop): the same citation injection, run inside the sandbox.
|
|
9
22
|
|
|
10
23
|
## How it works
|
|
11
24
|
|
|
@@ -17,10 +30,10 @@ For advanced use cases (PDF upload policy, citation style guidance, source trans
|
|
|
17
30
|
|
|
18
31
|
| Scenario | MCP server | Skill needed? |
|
|
19
32
|
|---|---|---|
|
|
20
|
-
| LLM with filesystem access (Claude Code, LM Studio, etc.) | All
|
|
33
|
+
| LLM with filesystem access (Claude Code, LM Studio, etc.) | All 20 tools | No |
|
|
21
34
|
| LLM without filesystem access (Claude.ai Projects, Claude Desktop) | API tools (search, add, metadata) | Yes, for citation injection |
|
|
22
35
|
|
|
23
|
-
LLMs with filesystem access can use all tools directly, including `inject_citations` which reads and writes `.docx` files on disk.
|
|
36
|
+
LLMs with filesystem access can use all tools directly, including `inject_citations` which reads and writes `.docx` (Word) and `.odt` (LibreOffice) files on disk.
|
|
24
37
|
|
|
25
38
|
LLMs without filesystem access — including Claude Desktop, which connects to MCP but cannot generate files locally — can use the included **Claude skill** (`skills/zotero-skill-mcp-integrations/`), which runs citation injection entirely inside a sandbox. MCP tools handle all Zotero API operations; the skill handles document assembly.
|
|
26
39
|
|
|
@@ -140,8 +153,8 @@ To enable deletion, set the `UNSAFE_OPERATIONS` environment variable to one of t
|
|
|
140
153
|
|
|
141
154
|
- If `UNSAFE_OPERATIONS` is not set, empty, or set to an unrecognized value, it defaults to `none`.
|
|
142
155
|
- The value is **case-insensitive** (e.g. `ALL`, `Items`, `NONE` all work).
|
|
143
|
-
- `delete_items`
|
|
144
|
-
- `delete_collection` removes the collection (folder) only — items inside it are **not** deleted and remain in your library.
|
|
156
|
+
- `delete_items` **moves items to the Zotero trash by default**, so the user can restore them from the desktop client (Zotero → Trash). Pass `permanent: true` to delete them for good instead; the same `UNSAFE_OPERATIONS` setting gates both the trash and the permanent path.
|
|
157
|
+
- `delete_collection` is always **permanent**: the Zotero Web API has no trash for collections, so a deleted collection cannot be restored. It removes the collection (folder) only — items inside it are **not** deleted and remain in your library.
|
|
145
158
|
- The `all` value includes both item and collection deletion because managing collections inherently requires item-level access.
|
|
146
159
|
|
|
147
160
|
**Configuration example:**
|
|
@@ -224,8 +237,10 @@ available in every project (the default `local` scope covers only the current pr
|
|
|
224
237
|
| `get_collections` | List all collections (folders) with keys, names, and parent relationships |
|
|
225
238
|
| `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
|
|
226
239
|
| `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
|
|
227
|
-
| `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.), the `authors` string
|
|
240
|
+
| `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.), the `authors` string, the structured `creators` and each item's current `version` (needed by `update_item`); keys not in the library are listed in `not_found` |
|
|
228
241
|
| `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
|
|
242
|
+
| `check_retractions` | Check items (by `item_keys` or `collection_key`) for retractions, expressions of concern or corrections via Crossref (Retraction Watch data). Read-only; reports a `status` per item with any notices |
|
|
243
|
+
| `find_duplicates` | Find duplicate items (same DOI or normalized title/year) in the library or a collection; or, given DOIs, report which are already present (use before `add_items_by_doi`). Read-only — never merges or deletes |
|
|
229
244
|
|
|
230
245
|
### Adding content
|
|
231
246
|
|
|
@@ -238,26 +253,65 @@ available in every project (the default `local` scope covers only the current pr
|
|
|
238
253
|
| `find_and_attach_pdfs` | Batch OA PDF lookup and auto-attach via Unpaywall (by item keys or collection) |
|
|
239
254
|
| `add_linked_url_attachment` | Attach a URL to an existing item or create a standalone link |
|
|
240
255
|
|
|
256
|
+
### Editing content
|
|
257
|
+
|
|
258
|
+
| Tool | Description |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `update_item` | Edit an existing item's fields and/or creators. Needs the item's current `version` (from `get_items_details`); a stale version returns `version_conflict` instead of overwriting |
|
|
261
|
+
| `create_note` | Attach a plain-text child note to an existing regular item (blank lines become paragraphs) |
|
|
262
|
+
|
|
241
263
|
### Deleting content
|
|
242
264
|
|
|
243
265
|
| Tool | Description |
|
|
244
266
|
|---|---|
|
|
245
|
-
| `delete_items` |
|
|
246
|
-
| `delete_collection` | Delete a collection (folder). Items inside are kept. Requires `UNSAFE_OPERATIONS=all` |
|
|
267
|
+
| `delete_items` | Delete up to 50 items per call: moved to the Zotero trash by default (recoverable), or permanently with `permanent: true`. Requires `UNSAFE_OPERATIONS=items` or `all` |
|
|
268
|
+
| `delete_collection` | Delete a collection (folder) permanently — collections have no trash. Items inside are kept. Requires `UNSAFE_OPERATIONS=all` |
|
|
247
269
|
|
|
248
270
|
### Citation & documents
|
|
249
271
|
|
|
250
272
|
| Tool | Description |
|
|
251
273
|
|---|---|
|
|
252
|
-
| `inject_citations` | Inject live Zotero citations into a Word document. Supports APA, IEEE, Vancouver, Harvard, Chicago. Output is saved in the same folder as the input file with a `_cited` suffix (e.g. `paper.docx` → `paper_cited.docx`) |
|
|
274
|
+
| `inject_citations` | Inject live Zotero citations into a Word (`.docx`) or LibreOffice (`.odt`) document (native fields in Word, reference marks in LibreOffice; format from the extension or the `format` argument). Recognizes two placeholder styles automatically: `<zcite keys="ITEMKEY"/>` tags, or — when the document has no `<zcite>` — Pandoc markers (`[@KEY]`, `[@K1; @K2]`, `[see @KEY, p. 12]`, `-@KEY`, inline `@KEY`), where `KEY` is a Zotero key or a case-sensitive Better BibTeX citation key (resolved via the item's `citationKey` field or a `Citation Key:` line in Extra). Prefer the bracketed `[@KEY]` form: an inline `@word` that doesn't resolve (a handle or code) is kept as text with a warning, while an unknown key in `[@…]` is an error. Supports APA, IEEE, Vancouver, Harvard, Chicago. Output is saved in the same folder as the input file with a `_cited` suffix (e.g. `paper.docx` → `paper_cited.docx`, `paper.odt` → `paper_cited.odt`) |
|
|
275
|
+
| `create_cited_document` | Create a new `.docx` or `.odt` from Markdown (headings, bold/italic, one-level lists) with pandoc-style citations (`[@KEY]`, `[@K1; @K2]`, `[see @KEY, p. 12]`, `-@KEY`) turned into native Zotero citations and a bibliography. Never overwrites an existing file |
|
|
253
276
|
| `get_user_id` | Returns the configured Zotero user ID |
|
|
254
277
|
|
|
278
|
+
### Citation placeholders
|
|
279
|
+
|
|
280
|
+
`inject_citations` (and the skill's `inject.js`) recognizes the placeholder style by itself, so there is no argument to set:
|
|
281
|
+
|
|
282
|
+
| The document contains | What happens |
|
|
283
|
+
|---|---|
|
|
284
|
+
| `<zcite keys="ITEMKEY"/>` tags | The tags become Zotero citations. Any Pandoc markers in the same document are left alone and reported in a warning |
|
|
285
|
+
| Only Pandoc markers | `[@KEY]`, `[@K1; @K2]`, `[see @KEY, p. 12]`, `-@KEY` (year only) and inline `@KEY` become Zotero citations |
|
|
286
|
+
| Neither | The copy is written unchanged, with a warning |
|
|
287
|
+
|
|
288
|
+
- `KEY` is a Zotero item key (8 characters) or a **Better BibTeX citation key** such as `rossi2020`, found through the item's citation key field or a `Citation Key:` line in Extra. Citation keys are case-sensitive.
|
|
289
|
+
- Prefer the bracketed form `[@KEY]`. An unknown or ambiguous key inside brackets is an error and nothing is written. An inline `@word` that doesn't resolve (a social handle, `@media`, a code decorator) stays text with a warning. Word may split an inline `@KEY` across formatting runs; the bracketed form is always recognized.
|
|
290
|
+
- A document with both styles: run `inject_citations` on the `_cited` output again to convert the Pandoc markers too. The second run adds the citations without a second bibliography; Zotero → Refresh updates the existing one.
|
|
291
|
+
- Footnotes and comments are not scanned, in either style.
|
|
292
|
+
|
|
293
|
+
### After injection
|
|
294
|
+
|
|
295
|
+
Open the `_cited` file in Word or LibreOffice with the [Zotero plugin](https://www.zotero.org/support/word_processor_integration) installed and use Zotero → Refresh: the plugin formats the citations and the bibliography in the chosen style, and from then on you can add, edit or restyle citations from the plugin as usual. The document stays in its format: a `.docx` keeps Word fields, an `.odt` keeps LibreOffice reference marks.
|
|
296
|
+
|
|
297
|
+
### Prompts
|
|
298
|
+
|
|
299
|
+
User-initiated MCP prompts (`prompts/list`, `prompts/get`) that walk a client through a workflow in terms of the tools above:
|
|
300
|
+
|
|
301
|
+
| Prompt | Description |
|
|
302
|
+
|---|---|
|
|
303
|
+
| `cite_word_document` | Turn the placeholders in an existing `.docx`/`.odt` into Zotero citations (`inject_citations`) |
|
|
304
|
+
| `write_cited_document` | Draft a cited document from scratch and save it (`create_cited_document`) |
|
|
305
|
+
| `import_references` | Import references by DOI: skip those already in the library (`find_duplicates`), add the rest (`add_items_by_doi`), check them for retractions (`check_retractions`) and attach open-access PDFs (`find_and_attach_pdfs`) |
|
|
306
|
+
| `note_from_item` | Read an item and attach a summary note (`create_note`) |
|
|
307
|
+
| `check_bibliography` | Check a collection for retracted items (`check_retractions`) and duplicates (`find_duplicates`) before handing it in |
|
|
308
|
+
|
|
255
309
|
## Development
|
|
256
310
|
|
|
257
311
|
```bash
|
|
258
312
|
npm install
|
|
259
313
|
npm run build # Compile TypeScript
|
|
260
|
-
npm test # Unit and per-tool tests (vitest, fetch faked, ~
|
|
314
|
+
npm test # Unit and per-tool tests (vitest, fetch faked, ~1900 tests)
|
|
261
315
|
npm run test:e2e # Built server over stdio against a local fake Zotero API
|
|
262
316
|
npm run smoke # Quick stdio smoke test of the built server (user + group library)
|
|
263
317
|
npm run bundle # Claude Desktop extension: dist/mcp-zotero.mcpb (production dependencies only)
|
|
@@ -350,7 +404,7 @@ Other owner settings:
|
|
|
350
404
|
and record the results in its log.
|
|
351
405
|
4. Before the first release that ships the Claude Desktop extension (and whenever `manifest.json` or the Node.js
|
|
352
406
|
requirement changes): build it with `npm run bundle`, install `dist/mcp-zotero.mcpb` once in a real Claude
|
|
353
|
-
Desktop (macOS or Windows), fill in a test key and user ID, and check that the
|
|
407
|
+
Desktop (macOS or Windows), fill in a test key and user ID, and check that the 20 tools appear and
|
|
354
408
|
`get_collections` answers.
|
|
355
409
|
5. Publish the GitHub release: the [release workflow](.github/workflows/release.yml) runs the tests, publishes
|
|
356
410
|
to npm, publishes `server.json` to the [MCP Registry](https://registry.modelcontextprotocol.io) and attaches
|
|
@@ -65,6 +65,9 @@ export function formatCitationText(items, style, opts = {}) {
|
|
|
65
65
|
const authors = item.author;
|
|
66
66
|
const name = (a) => a.family ?? a.literal ?? "Unknown";
|
|
67
67
|
const year = item.issued?.["date-parts"]?.[0]?.[0]?.toString() ?? "n.d.";
|
|
68
|
+
// suppress-author (pandoc -@K): solo l'anno, con eventuali locator/prefix/suffix
|
|
69
|
+
if (opts.suppressAuthor)
|
|
70
|
+
return `${prefix}${year}${locator}${suffix}`;
|
|
68
71
|
let authorText;
|
|
69
72
|
if (!authors || authors.length === 0) {
|
|
70
73
|
const title = item.title;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Parte neutra rispetto al formato della citazione: la costruzione delle voci CSL (itemData + URI) e del
|
|
4
|
+
* JSON CSL_CITATION che Zotero legge. La usano sia i campi Word (field-codes.ts) sia i reference mark ODT.
|
|
5
|
+
*/
|
|
6
|
+
const CSL_SCHEMA_URL = "https://github.com/citation-style-language/schema/raw/master/csl-citation.json";
|
|
7
|
+
/** Voci `citationItems` di una citazione: una per chiave, con URI Zotero e itemData. */
|
|
8
|
+
export function buildCitationItems(request, cslData, libraryType, userId) {
|
|
9
|
+
return request.keys.map((key, idx) => {
|
|
10
|
+
const itemData = cslData.get(key) ?? { type: "article-journal" };
|
|
11
|
+
const uri = `http://zotero.org/${libraryType}s/${userId}/items/${key}`;
|
|
12
|
+
const item = {
|
|
13
|
+
id: idx,
|
|
14
|
+
uris: [uri],
|
|
15
|
+
uri: [uri],
|
|
16
|
+
itemData,
|
|
17
|
+
};
|
|
18
|
+
if (request.locator)
|
|
19
|
+
item.locator = request.locator;
|
|
20
|
+
if (request.prefix)
|
|
21
|
+
item.prefix = request.prefix;
|
|
22
|
+
if (request.suffix)
|
|
23
|
+
item.suffix = request.suffix;
|
|
24
|
+
if (request.suppressAuthor)
|
|
25
|
+
item["suppress-author"] = true;
|
|
26
|
+
return item;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Oggetto CSL_CITATION (citationID casuale, formattedCitation, citationItems, schema). */
|
|
30
|
+
export function buildCslCitation(citationItems, formattedText) {
|
|
31
|
+
return {
|
|
32
|
+
citationID: randomUUID().slice(0, 8),
|
|
33
|
+
properties: { formattedCitation: formattedText },
|
|
34
|
+
citationItems,
|
|
35
|
+
schema: CSL_SCHEMA_URL,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { InjectionError } from "./model.js";
|
|
2
|
+
// Same rule as ZOTERO_KEY in src/utils/zotero-keys.ts, inlined so this module (shared with the skill
|
|
3
|
+
// bundle) stays free of the zod import that file carries.
|
|
4
|
+
const ZOTERO_KEY = /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/;
|
|
5
|
+
/** The citekeys an item carries: the native `citationKey` field and every `Citation Key: <key>` line of Extra. */
|
|
6
|
+
export function itemCitekeys(item) {
|
|
7
|
+
const out = [];
|
|
8
|
+
if (typeof item.citationKey === "string" && item.citationKey)
|
|
9
|
+
out.push(item.citationKey);
|
|
10
|
+
if (typeof item.extra === "string") {
|
|
11
|
+
for (const line of item.extra.split(/\r?\n/)) {
|
|
12
|
+
const m = /^[ \t]*citation ?key[ \t]*:[ \t]*(\S+)[ \t]*$/i.exec(line);
|
|
13
|
+
if (m)
|
|
14
|
+
out.push(m[1]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** Exact (case-sensitive) match of `citekey` against an item's citationKey field or Extra line. */
|
|
20
|
+
export function itemMatchesCitekey(item, citekey) {
|
|
21
|
+
return itemCitekeys(item).includes(citekey);
|
|
22
|
+
}
|
|
23
|
+
/** The citekeys in these requests that are not already well-formed Zotero item keys. */
|
|
24
|
+
export function citekeysOf(requests) {
|
|
25
|
+
const out = new Set();
|
|
26
|
+
for (const r of requests)
|
|
27
|
+
for (const k of r.keys)
|
|
28
|
+
if (!ZOTERO_KEY.test(k))
|
|
29
|
+
out.add(k);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve Better BibTeX citekeys to Zotero keys. An inline `@key` that does not resolve (unknown or
|
|
34
|
+
* ambiguous) is DROPPED and kept as text, with a warning — a stray handle or code like `@Component` must
|
|
35
|
+
* not fail the whole document. A bracketed `[@key]` that is unknown or ambiguous is an error (nothing is
|
|
36
|
+
* written), the same contract as a missing Zotero key.
|
|
37
|
+
*
|
|
38
|
+
* Inline-only citekeys are looked up at most INLINE_LOOKUP_CAP distinct tokens per call, so a technical
|
|
39
|
+
* document full of `@decorator`/`@media` does not fan out into unbounded API searches: tokens past the cap
|
|
40
|
+
* are left as text with one summary warning. Zotero 8-char keys (no lookup) and bracketed `[@…]` forms (a
|
|
41
|
+
* real citation) never count toward the cap.
|
|
42
|
+
*/
|
|
43
|
+
export const INLINE_LOOKUP_CAP = 25;
|
|
44
|
+
export async function resolveCitekeys(requests, lookup) {
|
|
45
|
+
const citekeys = citekeysOf(requests);
|
|
46
|
+
if (citekeys.size === 0)
|
|
47
|
+
return { requests, warnings: [] };
|
|
48
|
+
// Citekeys used by a bracketed form must always be resolved (they error if unknown); inline-only
|
|
49
|
+
// citekeys are capped.
|
|
50
|
+
const bracketKeys = new Set();
|
|
51
|
+
for (const r of requests)
|
|
52
|
+
if (!r.inline)
|
|
53
|
+
for (const k of r.keys)
|
|
54
|
+
if (!ZOTERO_KEY.test(k))
|
|
55
|
+
bracketKeys.add(k);
|
|
56
|
+
const inlineOnly = [...citekeys].filter((k) => !bracketKeys.has(k)); // first-appearance order
|
|
57
|
+
const cappedOut = new Set(inlineOnly.slice(INLINE_LOOKUP_CAP));
|
|
58
|
+
const toLookup = new Set([...bracketKeys, ...inlineOnly.slice(0, INLINE_LOOKUP_CAP)]);
|
|
59
|
+
const found = toLookup.size > 0 ? await lookup(toLookup) : new Map();
|
|
60
|
+
const status = (ck) => {
|
|
61
|
+
const match = found.get(ck) ?? { keys: [] };
|
|
62
|
+
if (match.keys.length === 1)
|
|
63
|
+
return { ok: match.keys[0], detail: ck };
|
|
64
|
+
if (match.keys.length === 0) {
|
|
65
|
+
const sim = match.similar && match.similar.length ? ` (case-sensitive; did you mean ${match.similar.join(", ")}?)` : "";
|
|
66
|
+
return { problem: "unknown", detail: `${ck}${sim}` };
|
|
67
|
+
}
|
|
68
|
+
return { problem: "ambiguous", detail: `${ck} → ${match.keys.join(", ")}` };
|
|
69
|
+
};
|
|
70
|
+
const unknownBracket = [];
|
|
71
|
+
const ambiguousBracket = [];
|
|
72
|
+
const warnings = [];
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const r of requests) {
|
|
75
|
+
const resolvedKeys = [];
|
|
76
|
+
let drop = false;
|
|
77
|
+
for (const k of r.keys) {
|
|
78
|
+
if (ZOTERO_KEY.test(k)) {
|
|
79
|
+
resolvedKeys.push(k);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (r.inline && cappedOut.has(k)) {
|
|
83
|
+
// over the inline lookup cap: left as text, no per-key lookup (summary warning added below)
|
|
84
|
+
drop = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
const s = status(k);
|
|
88
|
+
if (s.ok) {
|
|
89
|
+
resolvedKeys.push(s.ok);
|
|
90
|
+
}
|
|
91
|
+
else if (r.inline) {
|
|
92
|
+
// an inline marker that does not resolve stays text (no error)
|
|
93
|
+
drop = true;
|
|
94
|
+
warnings.push(`"${r.raw}" did not match any item (citation key or Zotero key); kept as text.`);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
else if (s.problem === "ambiguous") {
|
|
98
|
+
ambiguousBracket.push(s.detail);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
unknownBracket.push(s.detail);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!drop && resolvedKeys.length === r.keys.length)
|
|
105
|
+
out.push({ ...r, keys: resolvedKeys });
|
|
106
|
+
}
|
|
107
|
+
if (cappedOut.size > 0) {
|
|
108
|
+
warnings.push(`${cappedOut.size} inline @ citation key(s) were left as text without a lookup (over the ${INLINE_LOOKUP_CAP}-key ` +
|
|
109
|
+
"limit for inline markers); use the [@key] form for citations beyond that limit.");
|
|
110
|
+
}
|
|
111
|
+
if (unknownBracket.length > 0) {
|
|
112
|
+
throw new InjectionError("missing_keys", `${unknownBracket.length} citation key(s) not found in the library: ${unknownBracket.join("; ")}. No file was written. ` +
|
|
113
|
+
"Citation keys are case-sensitive and matched against each item's citationKey field or a \"Citation Key: <key>\" line " +
|
|
114
|
+
"in Extra (Better BibTeX). Fix these keys, or cite the item by its 8-character Zotero key, and call again; for a group " +
|
|
115
|
+
"library pass library_type and library_id.");
|
|
116
|
+
}
|
|
117
|
+
if (ambiguousBracket.length > 0) {
|
|
118
|
+
throw new InjectionError("invalid_key", `${ambiguousBracket.length} citation key(s) match more than one item: ${ambiguousBracket.join("; ")}. No file was written. ` +
|
|
119
|
+
"Make the citation key unique in Zotero, or cite the item by its 8-character Zotero key, and call again.");
|
|
120
|
+
}
|
|
121
|
+
return { requests: out, warnings };
|
|
122
|
+
}
|
|
@@ -26,11 +26,14 @@ const escapeXml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(
|
|
|
26
26
|
function sessionId() {
|
|
27
27
|
return [...randomBytes(8)].map((b) => SESSION_ALPHABET[b % SESSION_ALPHABET.length]).join("");
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The document data Zotero serializes (data-version 3), citations in the text (no noteType). `fieldType`
|
|
31
|
+
* is Field for Word fields and ReferenceMark for LibreOffice reference marks.
|
|
32
|
+
*/
|
|
33
|
+
export function zoteroDocumentData(styleId, fieldType = "Field", session = sessionId()) {
|
|
31
34
|
return (`<data data-version="3"><session id="${session}"/>` +
|
|
32
35
|
`<style id="${escapeXml(styleId)}" locale="en-US" hasBibliography="1" bibliographyStyleHasBeenSet="0"/>` +
|
|
33
|
-
`<prefs><pref name="fieldType" value="
|
|
36
|
+
`<prefs><pref name="fieldType" value="${fieldType}"/></prefs></data>`);
|
|
34
37
|
}
|
|
35
38
|
/** The value split into ZOTERO_PREF_1…n chunks. */
|
|
36
39
|
export function prefChunks(value) {
|
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { escapeXml } from "./xml-utils.js";
|
|
2
|
+
import { buildCslCitation } from "./citation-json.js";
|
|
3
3
|
export function generateZoteroFieldCode(citationItems, formattedText) {
|
|
4
|
-
const
|
|
5
|
-
const cslCitation = {
|
|
6
|
-
citationID: citationId,
|
|
7
|
-
properties: { formattedCitation: formattedText },
|
|
8
|
-
citationItems,
|
|
9
|
-
schema: "https://github.com/citation-style-language/schema/raw/master/csl-citation.json",
|
|
10
|
-
};
|
|
4
|
+
const cslCitation = buildCslCitation(citationItems, formattedText);
|
|
11
5
|
const instrText = ` ADDIN ZOTERO_ITEM CSL_CITATION ${JSON.stringify(cslCitation)} `;
|
|
12
6
|
const escapedInstrText = escapeXml(instrText);
|
|
13
7
|
return [
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
import { InjectionError } from "../model.js";
|
|
3
|
+
import { regexEscape } from "../xml-utils.js";
|
|
4
|
+
import { normalizeZciteTags, normalizePandocMarkers } from "../zcite-normalizer.js";
|
|
5
|
+
import { generateZoteroFieldCode, generateBibliographyFieldCode } from "../field-codes.js";
|
|
6
|
+
import { EXISTING_PREFS_WARNING, PREFS_NOT_WRITTEN_WARNING, writeZoteroDocumentPrefs, } from "../document-prefs.js";
|
|
7
|
+
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.";
|
|
8
|
+
/**
|
|
9
|
+
* Sostituisce il tag <zcite> (escapato) con il field code Word. Caso A (il tag è l'unico contenuto di un
|
|
10
|
+
* <w:r>), caso B (il tag è inline con altro testo). La `<w:r>` iniziale può avere attributi
|
|
11
|
+
* (`<w:r w:rsidR="…">`, scritti da Word quando tiene traccia delle revisioni): `<w:r[^>]*>` li tollera.
|
|
12
|
+
* Prima venivano saltati in silenzio (found ma non injected); i documenti senza attributi non cambiano.
|
|
13
|
+
*/
|
|
14
|
+
function replaceZciteInXml(xml, escapedZciteTag, fieldCodeXml) {
|
|
15
|
+
// Case A (preferred): tag is the sole content of a <w:r>
|
|
16
|
+
// Use [^<]*(?:<(?!/w:rPr>)[^<]*)* instead of .*? inside <w:rPr> to prevent
|
|
17
|
+
// matching across element boundaries in minified (single-line) XML.
|
|
18
|
+
const soloRunRegex = new RegExp(`<w:r[^>]*>(?:<w:rPr>[^<]*(?:<(?!/w:rPr>)[^<]*)*</w:rPr>)?<w:t[^>]*>${regexEscape(escapedZciteTag)}</w:t></w:r>`);
|
|
19
|
+
if (soloRunRegex.test(xml)) {
|
|
20
|
+
return xml.replace(soloRunRegex, fieldCodeXml);
|
|
21
|
+
}
|
|
22
|
+
// Case B (fallback): tag is inline with other text
|
|
23
|
+
const inlineRegex = new RegExp(`(<w:r[^>]*>(?:<w:rPr>([^<]*(?:<(?!/w:rPr>)[^<]*)*)</w:rPr>)?<w:t[^>]*>)([^<]*)${regexEscape(escapedZciteTag)}([^<]*)(</w:t></w:r>)`);
|
|
24
|
+
const inlineMatch = xml.match(inlineRegex);
|
|
25
|
+
if (inlineMatch) {
|
|
26
|
+
const rPr = inlineMatch[2] ? `<w:rPr>${inlineMatch[2]}</w:rPr>` : "";
|
|
27
|
+
const textBefore = inlineMatch[3];
|
|
28
|
+
const textAfter = inlineMatch[4];
|
|
29
|
+
let replacement = "";
|
|
30
|
+
if (textBefore) {
|
|
31
|
+
replacement += `<w:r>${rPr}<w:t xml:space="preserve">${textBefore}</w:t></w:r>`;
|
|
32
|
+
}
|
|
33
|
+
replacement += fieldCodeXml;
|
|
34
|
+
if (textAfter) {
|
|
35
|
+
replacement += `<w:r>${rPr}<w:t xml:space="preserve">${textAfter}</w:t></w:r>`;
|
|
36
|
+
}
|
|
37
|
+
return xml.replace(inlineRegex, replacement);
|
|
38
|
+
}
|
|
39
|
+
return xml;
|
|
40
|
+
}
|
|
41
|
+
/** Scrittore Word: involucro sulle funzioni di oggi, l'output docx non cambia byte per byte. */
|
|
42
|
+
export const docxWriter = {
|
|
43
|
+
format: "docx",
|
|
44
|
+
fieldType: "Field",
|
|
45
|
+
bodyPart: "word/document.xml",
|
|
46
|
+
app: "Word",
|
|
47
|
+
invalidPackageMessage: (detail) => `Invalid .docx file: not a readable ZIP archive (${detail}). ${REGENERATE_DOCX}`,
|
|
48
|
+
bodyMissingMessage: `Invalid .docx file: word/document.xml not found. ${REGENERATE_DOCX}`,
|
|
49
|
+
sniff(zip) {
|
|
50
|
+
// un .odt passato come .docx: errore chiaro invece di "word/document.xml not found"
|
|
51
|
+
if (zip.file("content.xml") && !zip.file("word/document.xml")) {
|
|
52
|
+
throw new InjectionError("format_mismatch", 'This looks like a LibreOffice .odt, not a Word .docx. Use format "odt" (or a .odt file).');
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
normalize: (bodyXml) => normalizeZciteTags(bodyXml),
|
|
56
|
+
normalizePandoc: (bodyXml) => normalizePandocMarkers(bodyXml),
|
|
57
|
+
citationXml: (prepared) => generateZoteroFieldCode(prepared.items, prepared.formattedText),
|
|
58
|
+
replaceTag: (bodyXml, rawTag, citationXml) => replaceZciteInXml(bodyXml, rawTag, citationXml),
|
|
59
|
+
appendBibliography: (bodyXml) => bodyXml.replace("</w:body>", `${generateBibliographyFieldCode()}</w:body>`),
|
|
60
|
+
writePrefs: (zip, style) => writeZoteroDocumentPrefs(zip, style),
|
|
61
|
+
prefsWarnings: (outcome) => {
|
|
62
|
+
if (outcome === "kept")
|
|
63
|
+
return [EXISTING_PREFS_WARNING];
|
|
64
|
+
if (outcome === "skipped")
|
|
65
|
+
return [PREFS_NOT_WRITTEN_WARNING];
|
|
66
|
+
return [];
|
|
67
|
+
},
|
|
68
|
+
async skeleton() {
|
|
69
|
+
const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
70
|
+
const zip = new JSZip();
|
|
71
|
+
zip.file("[Content_Types].xml", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>');
|
|
72
|
+
zip.file("_rels/.rels", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
|
|
73
|
+
zip.file("word/_rels/document.xml.rels", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>');
|
|
74
|
+
// Heading 1..6 con outlineLvl: così il pannello di navigazione e il sommario di Word funzionano
|
|
75
|
+
const sizes = [36, 32, 28, 26, 24, 22];
|
|
76
|
+
const headings = sizes
|
|
77
|
+
.map((sz, i) => `<w:style w:type="paragraph" w:styleId="Heading${i + 1}"><w:name w:val="heading ${i + 1}"/><w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="${i}"/></w:pPr><w:rPr><w:b/><w:sz w:val="${sz}"/></w:rPr></w:style>`)
|
|
78
|
+
.join("");
|
|
79
|
+
zip.file("word/styles.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="${W}"><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>${headings}</w:styles>`);
|
|
80
|
+
zip.file("word/document.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${W}"><w:body><w:sectPr/></w:body></w:document>`);
|
|
81
|
+
return zip;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { InjectionError } from "../model.js";
|
|
4
|
+
import { escapeXml } from "../xml-utils.js";
|
|
5
|
+
import { buildCslCitation } from "../citation-json.js";
|
|
6
|
+
import { normalizeOdtZciteTags, normalizeOdtPandocMarkers } from "../odt-normalizer.js";
|
|
7
|
+
import { ZOTERO_STYLE_IDS, prefChunks, zoteroDocumentData } from "../document-prefs.js";
|
|
8
|
+
/**
|
|
9
|
+
* Scrittore LibreOffice (ODT): le citazioni sono reference mark, la bibliografia una text:section, le
|
|
10
|
+
* preferenze vanno in meta.xml con fieldType ReferenceMark. Condivide con Word tutto il nucleo neutro
|
|
11
|
+
* (parsing <zcite>, JSON CSL della citazione, risoluzione delle chiavi); qui vive solo ciò che è ODF.
|
|
12
|
+
*/
|
|
13
|
+
const ODT_MIME = "application/vnd.oasis.opendocument.text";
|
|
14
|
+
const REGENERATE_ODT = "The file is not a LibreOffice document or is corrupted: create it again as .odt and call again.";
|
|
15
|
+
/** 10 caratteri [0-9A-Za-z], il suffisso RND che Zotero accoda ai nomi dei reference mark. */
|
|
16
|
+
const RND_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
17
|
+
function rnd10() {
|
|
18
|
+
return [...randomBytes(10)].map((b) => RND_ALPHABET[b % RND_ALPHABET.length]).join("");
|
|
19
|
+
}
|
|
20
|
+
/** Reference mark di una citazione: start/end con lo stesso nome ZOTERO_ITEM CSL_CITATION {json} RND…, testo in mezzo. */
|
|
21
|
+
function citationXml(prepared) {
|
|
22
|
+
const csl = buildCslCitation(prepared.items, prepared.formattedText);
|
|
23
|
+
const name = escapeXml(`ZOTERO_ITEM CSL_CITATION ${JSON.stringify(csl)} RND${rnd10()}`);
|
|
24
|
+
return (`<text:reference-mark-start text:name="${name}"/>` +
|
|
25
|
+
escapeXml(prepared.formattedText) +
|
|
26
|
+
`<text:reference-mark-end text:name="${name}"/>`);
|
|
27
|
+
}
|
|
28
|
+
/** Sezione ZOTERO_BIBL seguita da un paragrafo vuoto, prima di </office:text>. */
|
|
29
|
+
function appendBibliography(contentXml) {
|
|
30
|
+
const name = escapeXml(`ZOTERO_BIBL {"uncited":[],"omitted":[],"custom":[]} CSL_BIBLIOGRAPHY RND${rnd10()}`);
|
|
31
|
+
const section = `<text:section text:name="${name}">` +
|
|
32
|
+
`<text:p text:style-name="Bibliography_20_1">[Bibliography will be generated by Zotero]</text:p>` +
|
|
33
|
+
`</text:section><text:p/>`;
|
|
34
|
+
return contentXml.replace("</office:text>", `${section}</office:text>`);
|
|
35
|
+
}
|
|
36
|
+
const BIBLIOGRAPHY_STYLE = '<style:style style:name="Bibliography_20_1" style:display-name="Bibliography 1" style:family="paragraph" style:parent-style-name="Standard" style:class="list"/>';
|
|
37
|
+
/** Definisce lo stile Bibliography_20_1 in styles.xml se manca (dentro office:styles, o creando il blocco). */
|
|
38
|
+
async function ensureBibliographyStyle(zip) {
|
|
39
|
+
const entry = zip.file("styles.xml");
|
|
40
|
+
if (!entry)
|
|
41
|
+
return;
|
|
42
|
+
let styles = await entry.async("string");
|
|
43
|
+
if (styles.includes('style:name="Bibliography_20_1"'))
|
|
44
|
+
return;
|
|
45
|
+
if (styles.includes("</office:styles>")) {
|
|
46
|
+
styles = styles.replace("</office:styles>", `${BIBLIOGRAPHY_STYLE}</office:styles>`);
|
|
47
|
+
}
|
|
48
|
+
else if (/<office:styles\s*\/>/.test(styles)) {
|
|
49
|
+
styles = styles.replace(/<office:styles\s*\/>/, `<office:styles>${BIBLIOGRAPHY_STYLE}</office:styles>`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
return; // nessun posto riconoscibile: lo stile non è definito, LibreOffice userà Standard
|
|
53
|
+
}
|
|
54
|
+
zip.file("styles.xml", styles);
|
|
55
|
+
}
|
|
56
|
+
export const EXISTING_ODT_PREFS_WARNING = "The document already has Zotero document preferences (ZOTERO_PREF_* in meta.xml): kept as they are, so on the " +
|
|
57
|
+
"first Refresh Zotero uses the style saved there, not the style of this injection.";
|
|
58
|
+
export const ODT_PREFS_NOT_WRITTEN_WARNING = "Zotero document preferences not written: meta.xml has no <office:meta> to merge into, so Zotero may ask for the " +
|
|
59
|
+
"document style (Document Preferences) on the first Refresh.";
|
|
60
|
+
/** Preferenze in meta.xml: meta:user-defined ZOTERO_PREF_1…n (255 caratteri), fieldType ReferenceMark. */
|
|
61
|
+
async function writeOdtPrefs(zip, style) {
|
|
62
|
+
const styleId = ZOTERO_STYLE_IDS[style];
|
|
63
|
+
if (!styleId)
|
|
64
|
+
return "skipped";
|
|
65
|
+
const entry = zip.file("meta.xml");
|
|
66
|
+
const meta = entry ? await entry.async("string") : undefined;
|
|
67
|
+
if (meta === undefined)
|
|
68
|
+
return "skipped";
|
|
69
|
+
if (/\bmeta:name=["']ZOTERO_PREF_\d+["']/.test(meta))
|
|
70
|
+
return "kept";
|
|
71
|
+
const value = zoteroDocumentData(styleId, "ReferenceMark");
|
|
72
|
+
const props = prefChunks(value)
|
|
73
|
+
.map((chunk, i) => `<meta:user-defined meta:name="ZOTERO_PREF_${i + 1}">${escapeXml(chunk)}</meta:user-defined>`)
|
|
74
|
+
.join("");
|
|
75
|
+
const close = "</office:meta>";
|
|
76
|
+
const at = meta.lastIndexOf(close);
|
|
77
|
+
if (at < 0) {
|
|
78
|
+
// office:meta vuoto <office:meta/>
|
|
79
|
+
const m = /<office:meta\b([^>]*)\/>/.exec(meta);
|
|
80
|
+
if (!m)
|
|
81
|
+
return "skipped";
|
|
82
|
+
zip.file("meta.xml", meta.replace(m[0], `<office:meta${m[1]}>${props}</office:meta>`));
|
|
83
|
+
return "written";
|
|
84
|
+
}
|
|
85
|
+
zip.file("meta.xml", meta.slice(0, at) + props + meta.slice(at));
|
|
86
|
+
return "written";
|
|
87
|
+
}
|
|
88
|
+
export const odtWriter = {
|
|
89
|
+
format: "odt",
|
|
90
|
+
fieldType: "ReferenceMark",
|
|
91
|
+
bodyPart: "content.xml",
|
|
92
|
+
app: "LibreOffice",
|
|
93
|
+
invalidPackageMessage: (detail) => `Invalid .odt file: not a readable ZIP archive (${detail}). ${REGENERATE_ODT}`,
|
|
94
|
+
bodyMissingMessage: `Invalid .odt file: content.xml not found. ${REGENERATE_ODT}`,
|
|
95
|
+
sniff(zip) {
|
|
96
|
+
// un .docx passato come .odt (o viceversa): meglio un errore chiaro di un "content.xml not found"
|
|
97
|
+
if (zip.file("word/document.xml")) {
|
|
98
|
+
throw new InjectionError("format_mismatch", "This looks like a Word .docx, not a LibreOffice .odt. Use format \"docx\" (or a .docx file).");
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
normalize: (bodyXml) => normalizeOdtZciteTags(bodyXml),
|
|
102
|
+
normalizePandoc: (bodyXml) => normalizeOdtPandocMarkers(bodyXml),
|
|
103
|
+
citationXml,
|
|
104
|
+
replaceTag: (bodyXml, rawTag, cite) => {
|
|
105
|
+
const at = bodyXml.indexOf(rawTag);
|
|
106
|
+
return at < 0 ? bodyXml : bodyXml.slice(0, at) + cite + bodyXml.slice(at + rawTag.length);
|
|
107
|
+
},
|
|
108
|
+
appendBibliography,
|
|
109
|
+
writePrefs: writeOdtPrefs,
|
|
110
|
+
prefsWarnings: (outcome) => {
|
|
111
|
+
if (outcome === "kept")
|
|
112
|
+
return [EXISTING_ODT_PREFS_WARNING];
|
|
113
|
+
if (outcome === "skipped")
|
|
114
|
+
return [ODT_PREFS_NOT_WRITTEN_WARNING];
|
|
115
|
+
return [];
|
|
116
|
+
},
|
|
117
|
+
async finalize(zip) {
|
|
118
|
+
await ensureBibliographyStyle(zip);
|
|
119
|
+
// mimetype deve restare STORED (non compresso): lo riscrivo con STORE, mantenendo la posizione
|
|
120
|
+
const mimetype = zip.file("mimetype");
|
|
121
|
+
if (mimetype)
|
|
122
|
+
zip.file("mimetype", await mimetype.async("uint8array"), { compression: "STORE" });
|
|
123
|
+
},
|
|
124
|
+
generateOptions: { compression: "DEFLATE" },
|
|
125
|
+
async skeleton() {
|
|
126
|
+
const NS = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" ' +
|
|
127
|
+
'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' +
|
|
128
|
+
'xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"';
|
|
129
|
+
const zip = new JSZip();
|
|
130
|
+
zip.file("mimetype", ODT_MIME, { compression: "STORE" });
|
|
131
|
+
zip.file("META-INF/manifest.xml", '<?xml version="1.0" encoding="UTF-8"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">' +
|
|
132
|
+
`<manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="${ODT_MIME}"/>` +
|
|
133
|
+
'<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>' +
|
|
134
|
+
'<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>' +
|
|
135
|
+
'<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/></manifest:manifest>');
|
|
136
|
+
zip.file("content.xml", `<?xml version="1.0" encoding="UTF-8"?><office:document-content ${NS}>` +
|
|
137
|
+
'<office:automatic-styles>' +
|
|
138
|
+
'<style:style style:name="T_Bold" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style>' +
|
|
139
|
+
'<style:style style:name="T_Italic" style:family="text"><style:text-properties fo:font-style="italic"/></style:style>' +
|
|
140
|
+
'</office:automatic-styles><office:body><office:text></office:text></office:body></office:document-content>');
|
|
141
|
+
const headingSizes = ["18pt", "16pt", "14pt", "13pt", "12pt", "11pt"];
|
|
142
|
+
const headings = headingSizes
|
|
143
|
+
.map((sz, i) => `<style:style style:name="Heading_20_${i + 1}" style:display-name="Heading ${i + 1}" style:family="paragraph" style:default-outline-level="${i + 1}" style:class="text"><style:text-properties fo:font-weight="bold" fo:font-size="${sz}"/></style:style>`)
|
|
144
|
+
.join("");
|
|
145
|
+
zip.file("styles.xml", `<?xml version="1.0" encoding="UTF-8"?><office:document-styles ${NS}><office:styles>${headings}</office:styles></office:document-styles>`);
|
|
146
|
+
zip.file("meta.xml", `<?xml version="1.0" encoding="UTF-8"?><office:document-meta ${NS}><office:meta/></office:document-meta>`);
|
|
147
|
+
return zip;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|