@xevos117/mcp-zotero 2.0.2 → 2.0.4

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.
@@ -3,9 +3,10 @@ import { zoteroKey } from "../utils/zotero-keys.js";
3
3
  import { formatJsonResult, formatErrorResponse, errorMessage, logToolError } from "../utils/error-formatter.js";
4
4
  import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
5
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
+ import { COLLECTION_KEY_HINT, ITEM_KEY_HINT, MISSING_API_KEY, MISSING_API_KEY_HINT, describeAnyError, zoteroStatusHint } from "../utils/error-hints.js";
6
7
  export const toolConfig = {
7
8
  name: "import_pdf_to_zotero",
8
- description: "Download a PDF from a URL and upload it to Zotero storage as an imported_url attachment. Unlike linked URL attachments, imported files are stored in Zotero's storage and become fulltext-indexed (searchable via get_item_fulltext). Use this when you need the PDF content to be indexed by Zotero.",
9
+ description: "Download a PDF from a URL and upload it to Zotero storage as an imported_url attachment. Unlike linked URL attachments, imported files are stored in Zotero's storage and become fulltext-indexed (searchable via get_item_fulltext). Use this when you need the PDF content to be indexed by Zotero. The URL must lead straight to the PDF file (no login, paywall or HTML landing page); files up to 100 MB, counted against the user's Zotero file storage. For items with a DOI, find_and_attach_pdfs finds open-access PDFs by itself.",
9
10
  inputSchema: {
10
11
  url: z.string().url().describe("URL of the PDF to download and import"),
11
12
  filename: z
@@ -36,12 +37,33 @@ export const toolConfig = {
36
37
  },
37
38
  };
38
39
  const ImportPdfSchema = z.object(toolConfig.inputSchema);
40
+ /** Next step for an HTTP error from the site serving the PDF. */
41
+ function downloadFailureHint(status) {
42
+ if (status === 401 || status === 402 || status === 403) {
43
+ return "The site refuses the download (login, paywall or bot protection): try an open-access copy (find_and_attach_pdfs for an item with a DOI, or a repository link), or the user downloads it in a browser and adds it in Zotero desktop.";
44
+ }
45
+ if (status === 404 || status === 410)
46
+ return "The URL does not exist: check it for typos or find the current PDF link.";
47
+ if (status === 429 || (status !== undefined && status >= 500))
48
+ return "The site is busy or failing: wait a minute and retry once.";
49
+ return "Check that the URL leads straight to the PDF file and retry.";
50
+ }
51
+ /** Zotero storage step failed after the attachment record was created: say so, and what to do next. */
52
+ function uploadFailureHint(status, itemKey) {
53
+ const next = status === 401 || status === 403
54
+ ? zoteroStatusHint(status).hint
55
+ : "Retry once after a short wait (timeouts and 5xx errors are usually temporary).";
56
+ return {
57
+ ...(itemKey ? { item_key: itemKey } : {}),
58
+ hint: `${itemKey ? "An attachment record (item_key) was created without the file; a retry creates a new one, so mention it to the user (they can delete it in Zotero). " : ""}${next}`,
59
+ };
60
+ }
39
61
  export async function handleImportPdfToZotero(zoteroApi, userId, args) {
40
62
  const { url, filename, title, content_type, parent_item, collections, tags, library_type, library_id } = ImportPdfSchema.parse(args);
41
63
  const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
42
64
  const apiKey = process.env.ZOTERO_API_KEY;
43
65
  if (!apiKey) {
44
- return formatErrorResponse("ZOTERO_API_KEY environment variable is not set");
66
+ return formatErrorResponse(MISSING_API_KEY, { hint: MISSING_API_KEY_HINT });
45
67
  }
46
68
  try {
47
69
  const result = await downloadAndUploadPdf(zoteroApi, libraryType, libraryId, apiKey, {
@@ -59,48 +81,59 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
59
81
  return formatErrorResponse("Network error downloading file", {
60
82
  url,
61
83
  details: result.error.networkDetail,
62
- suggestion: "The server may be blocking automated requests, the URL may redirect to an HTML page, or it may require authentication. Try a direct PDF link from another source (e.g. PubMed Central, Sci-Hub, or the publisher's direct PDF endpoint).",
84
+ suggestion: "The site may be unreachable, block automated downloads or require a login. Retry once if it looks temporary (timeout, connection reset); otherwise try a direct PDF link from an open source (PubMed Central, arXiv, an institutional repository, the publisher's PDF link) or, for an item with a DOI, find_and_attach_pdfs. If the user has the PDF, they can add it in Zotero desktop.",
63
85
  });
64
86
  case "download_failed":
65
- return formatErrorResponse("Failed to download file from URL", {
87
+ return formatErrorResponse(`Failed to download file from URL (HTTP ${result.error.status})`, {
66
88
  url,
67
89
  status: result.error.status,
90
+ suggestion: downloadFailureHint(result.error.status),
68
91
  });
69
92
  case "file_too_large":
70
93
  return formatErrorResponse("File exceeds 100 MB limit", {
71
94
  size_bytes: result.error.sizeBytes,
95
+ suggestion: "This tool imports files up to 100 MB; nothing was created. The user can add larger files in Zotero desktop.",
72
96
  });
73
97
  case "storage_quota_exceeded":
74
98
  return formatErrorResponse("Zotero storage quota exceeded", {
75
99
  status: result.error.status,
76
100
  suggestion: "Your Zotero cloud storage is full. Free up space by deleting stored files in your Zotero library, or upgrade your storage plan at https://www.zotero.org/settings/storage. Note: item metadata and linked URL attachments do not consume storage — only imported file attachments do.",
77
101
  item_key: result.itemKey,
78
- note: "An attachment metadata record was created but the file could not be uploaded. You may want to delete it with delete_items or retry after freeing storage.",
102
+ note: "An attachment record (item_key) was created without the file. Tell the user: they can delete it in Zotero (or with delete_items, if deletion is enabled) and retry after freeing storage.",
79
103
  });
80
104
  case "auth_failed":
81
105
  return formatErrorResponse("Upload authorization failed", {
82
106
  status: result.error.status,
83
107
  details: result.error.networkDetail, // e.g. the timeout or connection error
108
+ ...uploadFailureHint(result.error.status, result.itemKey),
84
109
  });
85
110
  case "upload_failed":
86
111
  return formatErrorResponse("File upload failed", {
87
112
  status: result.error.status,
88
113
  details: result.error.networkDetail, // e.g. the timeout or connection error
114
+ ...uploadFailureHint(result.error.status, result.itemKey),
89
115
  });
90
116
  case "not_pdf":
91
117
  return formatErrorResponse("Downloaded file is not a valid PDF", {
92
118
  url,
93
119
  details: result.error.message,
94
- suggestion: "The URL may redirect to an HTML page or require browser access. Try a direct PDF link.",
120
+ suggestion: "Nothing was created. The URL may redirect to an HTML page or require browser access: use the direct PDF link (on arXiv /pdf/ instead of /abs/), or for an item with a DOI try find_and_attach_pdfs.",
95
121
  });
96
122
  case "registration_failed":
97
123
  return formatErrorResponse("Upload registration failed", {
98
124
  status: result.error.status,
99
125
  details: result.error.networkDetail, // e.g. the timeout or connection error
126
+ ...uploadFailureHint(result.error.status, result.itemKey),
100
127
  });
101
128
  case "item_creation_failed":
102
129
  return formatErrorResponse("import_pdf_to_zotero failed", {
103
130
  details: result.error.message,
131
+ ...(result.error.status !== undefined ? { status: result.error.status } : {}),
132
+ hint: result.error.status !== undefined && result.error.status !== 400
133
+ ? `Nothing was created. ${zoteroStatusHint(result.error.status).hint}`
134
+ : parent_item
135
+ ? `Zotero refused to create the attachment (details). parent_item must be an existing regular item, not an attachment or note: ${ITEM_KEY_HINT}`
136
+ : `Zotero refused to create the attachment (details). Check the collections keys: ${COLLECTION_KEY_HINT}`,
104
137
  });
105
138
  }
106
139
  }
@@ -118,6 +151,7 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
118
151
  }
119
152
  catch (err) {
120
153
  logToolError("import_pdf_to_zotero", err);
121
- return formatErrorResponse("import_pdf_to_zotero failed", { details: errorMessage(err) });
154
+ const { error, ...described } = describeAnyError(err);
155
+ return formatErrorResponse(`import_pdf_to_zotero failed: ${error}`, { ...described, details: errorMessage(err) });
122
156
  }
123
157
  }
@@ -13,7 +13,8 @@ import { handleImportPdfToZotero, toolConfig as importPdfToZoteroConfig } from "
13
13
  import { handleFindAndAttachPdfs, toolConfig as findAndAttachPdfsConfig } from "./find-and-attach-pdfs.js";
14
14
  import { handleDeleteCollection, toolConfig as deleteCollectionConfig } from "./delete-collection.js";
15
15
  import { handleDeleteItems, toolConfig as deleteItemsConfig } from "./delete-items.js";
16
- import { logToolError } from "../utils/error-formatter.js";
16
+ import { formatErrorResponse, logToolError } from "../utils/error-formatter.js";
17
+ import { describeError } from "../utils/error-hints.js";
17
18
  const tools = [
18
19
  { config: collectionsConfig, handler: handleGetCollections },
19
20
  { config: collectionItemsConfig, handler: handleGetCollectionItems },
@@ -32,14 +33,21 @@ const tools = [
32
33
  { config: deleteItemsConfig, handler: handleDeleteItems },
33
34
  ];
34
35
  const handlers = new Map(tools.map(({ config, handler }) => [config.name, handler]));
35
- /** Run a tool handler; Zotero API errors it throws are logged here once for every tool. */
36
+ /**
37
+ * Run a tool handler; Zotero API errors it throws are logged here once for every tool. API, timeout and
38
+ * network errors become a structured error with a hint on what to do next; anything else (a bug) is rethrown.
39
+ */
36
40
  async function runTool(name, handler, zoteroApi, userId, args, unsafeOps) {
37
41
  try {
38
42
  return await handler(zoteroApi, userId, args, unsafeOps);
39
43
  }
40
44
  catch (err) {
41
45
  logToolError(name, err);
42
- throw err;
46
+ const described = describeError(err);
47
+ if (!described)
48
+ throw err;
49
+ const { error, ...details } = described;
50
+ return formatErrorResponse(error, details);
43
51
  }
44
52
  }
45
53
  export async function handleToolCall(name, args, zoteroApi, userId, unsafeOps = "none") {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
+ import { isAbsolute } from "node:path";
3
4
  import { injectCitations } from "../citation-injector/injector.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
5
6
  export const toolConfig = {
@@ -7,13 +8,14 @@ export const toolConfig = {
7
8
  description: `Replace <zcite> placeholder tags in a .docx file with native Zotero field codes that Zotero for Word can recognize and manage. The tool fetches item metadata from Zotero automatically — you only need to provide the .docx file.
8
9
 
9
10
  WORKFLOW — how to create a Word document with live Zotero citations:
10
- 1. Collect item keys: use add_items_by_doi (or search_library for existing items)
11
+ 1. Collect item keys: use add_items_by_doi or add_items (or search_library for existing items)
11
12
  2. Generate .docx: create a Word document (e.g. with the "docx" npm package) with <zcite keys="ITEMKEY"/> placeholders where citations should appear. Each <zcite> MUST be in its own dedicated TextRun — do NOT mix it with surrounding text.
12
13
  3. Call this tool with the .docx file path. It replaces every zcite tag with a Zotero field code and appends a bibliography.
13
14
  4. Tell the user to open the file in Word with the Zotero plugin, click Zotero → Add/Edit Bibliography, then Zotero → Refresh.
14
15
 
15
16
  CITATION STYLES — ask the user which style they want before generating:
16
17
  - apa (default): author-year — (Smith, 2023)
18
+ - harvard / chicago: author-date — (Smith, 2023) / (Smith 2023)
17
19
  - ieee / vancouver: numbered — [1], [2]
18
20
  WARNING: for numbered styles every <zcite> MUST include a num="N" attribute with the sequential citation number. Without num, citations render as [?].
19
21
 
@@ -22,14 +24,14 @@ ZCITE TAG FORMAT:
22
24
  Supported attributes (any order):
23
25
  - keys (required): item key or comma-separated keys — "ABC12345" or "ABC12345,DEF67890"
24
26
  - num: citation number for IEEE/Vancouver — "1" or "1,2" (required for numbered styles)
25
- - locator: page reference — "pp. 12-15"
26
- - prefix: text before citation — "see "
27
+ - locator: page reference — "12-15" (shown as "pp. 12–15"; other labels explicit, e.g. "chap. 3")
28
+ - prefix: text before citation — "see"
27
29
  - suffix: text after citation — ", emphasis added"
28
30
 
29
31
  OUTPUT:
30
- A new .docx file (original filename with _cited suffix) with Zotero field codes and a ZOTERO_BIBL bibliography at the end. A cited key that is not in the library, or that is not letters and digits only, is an error and no file is written.
32
+ A new .docx file (original filename with _cited suffix) with Zotero field codes and a ZOTERO_BIBL bibliography at the end. A cited key that is not in the library, or that is not a Zotero key (8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ), is an error and no file is written.
31
33
 
32
- NOTE: If the inject-citations skill is available, prefer the skill workflow (runs in sandbox, no filesystem dependency). This tool serves as the primary path when the skill is not available.`,
34
+ WHERE THE FILE LIVES: this tool reads and writes files on the machine where this MCP server runs. If you create the .docx in a sandbox or cloud workspace that the server cannot see (for example code execution in Claude.ai or Claude Desktop, paths like /mnt/user-data/...), do not call this tool: use the zotero-mcp-integrations skill, which injects the citations inside the sandbox with metadata from get_items_details. If that skill is not installed, recommend it to the user: zotero-skill-mcp-integrations.zip from https://github.com/Xevos117/mcp-zotero/releases/latest.`,
33
35
  inputSchema: {
34
36
  file_path: z
35
37
  .string()
@@ -43,15 +45,40 @@ NOTE: If the inject-citations skill is available, prefer the skill workflow (run
43
45
  },
44
46
  };
45
47
  const InjectCitationsSchema = z.object(toolConfig.inputSchema);
48
+ const FILE_NOT_FOUND = "File not found on the machine where this MCP server runs. If the document is in a sandbox or cloud workspace " +
49
+ "(for example code execution in Claude.ai or Claude Desktop), this server cannot read it: use the " +
50
+ "zotero-mcp-integrations skill, which injects the citations inside the sandbox. If the skill is not installed, " +
51
+ "ask the user to add zotero-skill-mcp-integrations.zip from https://github.com/Xevos117/mcp-zotero/releases/latest.";
46
52
  export async function handleInjectCitations(zoteroApi, userId, args) {
47
53
  const { file_path, style, library_type, library_id } = InjectCitationsSchema.parse(args);
48
54
  const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
49
55
  if (!file_path.endsWith(".docx")) {
50
56
  return formatErrorResponse("File must be a .docx file", {
51
57
  file_path,
58
+ hint: "Only Word .docx files are supported and the name must end in lowercase .docx: convert .doc, .odt or .rtf to .docx first, or create the document as .docx with <zcite> placeholders.",
59
+ });
60
+ }
61
+ let result;
62
+ try {
63
+ result = await injectCitations(file_path, zoteroApi, libraryType, libraryId, style);
64
+ }
65
+ catch (err) {
66
+ // A path from a sandbox or cloud workspace (Claude.ai, Claude Desktop code execution) does not exist here:
67
+ // say so and point to the skill instead of a bare ENOENT.
68
+ const code = err.code;
69
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
70
+ return formatErrorResponse(`Cannot read the file or write the _cited copy next to it (${code}).`, {
71
+ file_path,
72
+ hint: "Copy the .docx to a folder this server can write to (e.g. the user's Documents) and call again with that path.",
73
+ });
74
+ }
75
+ if (code !== "ENOENT")
76
+ throw err;
77
+ return formatErrorResponse(FILE_NOT_FOUND, {
78
+ file_path,
79
+ ...(isAbsolute(file_path) ? {} : { hint: "Pass an absolute path." }),
52
80
  });
53
81
  }
54
- const result = await injectCitations(file_path, zoteroApi, libraryType, libraryId, style);
55
82
  const responseObj = {
56
83
  output_path: result.outputPath,
57
84
  citations_found: result.found,
@@ -6,8 +6,8 @@ export const toolConfig = {
6
6
  name: "search_library",
7
7
  description: `Search your Zotero library or list items sorted by a field.
8
8
 
9
- When 'query' is provided, searches by title, author, or any field.
10
- When 'query' is omitted, lists items sorted by the chosen field (default: dateAdded, descending) — this replaces the old get_recent tool.
9
+ When 'query' is provided, matches it against titles, creator names and years (not abstracts, tags or full text).
10
+ When 'query' is omitted, lists items sorted by the chosen field (default: dateAdded, descending).
11
11
 
12
12
  Examples:
13
13
  - Search: { "query": "deep learning" }
@@ -32,9 +32,11 @@ Use the returned item keys with get_items_details, get_item_fulltext, or inject_
32
32
  .describe("Sort direction (default: desc)"),
33
33
  limit: z
34
34
  .number()
35
+ .int()
36
+ .min(1)
35
37
  .optional()
36
38
  .default(25)
37
- .describe("Maximum number of items to return (default: 25, max: 100)"),
39
+ .describe("Maximum number of items to return, 1-100 (default: 25; above 100 returns 100)"),
38
40
  ...libraryArgsSchema,
39
41
  },
40
42
  };
@@ -59,11 +61,11 @@ export async function handleSearchLibrary(zoteroApi, userId, args) {
59
61
  if (query) {
60
62
  return formatEmptyResult("No results found", {
61
63
  query,
62
- suggestion: "Try a different search term or verify your library contains matching items",
64
+ suggestion: "Nothing in this library matches the query in title, creator or year. Try fewer or different words (one author surname or a title keyword), or omit query to list recent items. For a group library pass library_type and library_id.",
63
65
  });
64
66
  }
65
67
  return formatEmptyResult("No items found", {
66
- suggestion: "Add some items to your Zotero library first",
68
+ suggestion: "This library has no items yet. Add some with add_items_by_doi (items with a DOI) or add_items.",
67
69
  });
68
70
  }
69
71
  const formatted = items.map((item) => ({
@@ -1,4 +1,4 @@
1
- import { ITEM_TYPE_FIELDS } from "./zotero-item-types.js";
1
+ import { ITEM_TYPE_CREATOR_TYPES, ITEM_TYPE_FIELDS } from "./zotero-item-types.js";
2
2
  const CSL_TO_ZOTERO_TYPE = {
3
3
  // Standard CSL types
4
4
  "article": "journalArticle",
@@ -112,13 +112,50 @@ const CONTAINER_TITLE_FIELD = {
112
112
  tvBroadcast: "programTitle",
113
113
  podcast: "programTitle",
114
114
  };
115
+ /**
116
+ * CSL name variables ⇄ Zotero creator types, as in Zotero's CSL mappings. Authors first: the
117
+ * order of the CSL arrays becomes the order of the Zotero creators.
118
+ */
119
+ const CSL_NAME_CREATOR_TYPES = [
120
+ ["author", "author"],
121
+ ["editor", "editor"],
122
+ ["container-author", "bookAuthor"],
123
+ ["collection-editor", "seriesEditor"],
124
+ ["translator", "translator"],
125
+ ];
126
+ /** Zotero field holding the CSL publisher, for the types that call it otherwise (Zotero's base field mappings). */
127
+ const PUBLISHER_FIELD = {
128
+ thesis: "university",
129
+ report: "institution",
130
+ preprint: "repository",
131
+ };
132
+ /** Zotero field holding the CSL number (report number, standard or manuscript number). */
133
+ const NUMBER_FIELD = {
134
+ report: "reportNumber",
135
+ standard: "number",
136
+ manuscript: "number",
137
+ };
138
+ /** arXiv DOIs (DataCite): the item is a preprint in the arXiv repository. */
139
+ const ARXIV_DOI = /^10\.48550\/arxiv\.(.+)$/i;
140
+ /** JATS abstract (Crossref: <jats:p>, <jats:italic>, …) → plain text, one line per paragraph, as Zotero's import does. */
141
+ function plainAbstract(abstract) {
142
+ if (!/<jats:/.test(abstract))
143
+ return abstract;
144
+ return abstract
145
+ .split(/<\/jats:p>/)
146
+ .map((p) => p.replace(/<\/?jats:[^>]*>/g, " ").replace(/\s+/g, " ").trim())
147
+ .filter(Boolean)
148
+ .join("\n");
149
+ }
115
150
  export function cslToZoteroItem(csl, options) {
116
- const itemType = (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle");
117
- const creators = (csl.author ?? []).map((a) => ({
118
- firstName: a.given ?? "",
119
- lastName: a.family ?? a.literal ?? "",
120
- creatorType: "author",
121
- }));
151
+ const arxiv = ARXIV_DOI.exec(csl.DOI ?? "");
152
+ const itemType = (arxiv ? "preprint" : (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle"));
153
+ // Authors always (as before); other roles only where the item type has them, or the API rejects the item.
154
+ // A CSL `literal` (institution, consortium) is Zotero's single-field name
155
+ const validCreatorTypes = ITEM_TYPE_CREATOR_TYPES[itemType];
156
+ const creators = CSL_NAME_CREATOR_TYPES.flatMap(([variable, creatorType]) => creatorType !== "author" && !validCreatorTypes?.has(creatorType)
157
+ ? []
158
+ : (csl[variable] ?? []).map((a) => a.literal && !a.family ? { name: a.literal, creatorType } : { firstName: a.given ?? "", lastName: a.family ?? "", creatorType }));
122
159
  const dateParts = csl.issued?.["date-parts"]?.[0];
123
160
  const date = dateParts ? dateParts.join("-") : csl.issued?.literal ?? csl.issued?.raw ?? "";
124
161
  const collections = [];
@@ -126,25 +163,39 @@ export function cslToZoteroItem(csl, options) {
126
163
  collections.push(options.collectionKey);
127
164
  }
128
165
  const tags = (options?.tags ?? []).map((t) => ({ tag: t }));
166
+ // Zotero joins the subtitle to the title; the article number is the item's number, or its pages when it has none
167
+ const subtitle = Array.isArray(csl.subtitle) ? csl.subtitle[0] : csl.subtitle;
168
+ const title = subtitle ? `${csl.title ?? ""}: ${subtitle}` : (csl.title ?? "");
169
+ const number = csl.number ?? csl["article-number"] ?? "";
170
+ const numberField = NUMBER_FIELD[itemType];
129
171
  // Build candidate fields (CSL → Zotero field names)
130
172
  const candidates = {
131
- title: csl.title ?? "",
173
+ title,
132
174
  date,
133
175
  DOI: csl.DOI ?? "",
134
176
  volume: csl.volume ?? "",
135
177
  issue: csl.issue ?? "",
136
- pages: csl.page ?? "",
178
+ pages: csl.page ?? (numberField ? "" : (csl["article-number"] ?? "")),
137
179
  publisher: csl.publisher ?? "",
138
180
  place: csl["publisher-place"] ?? "",
139
181
  url: csl.URL ?? "",
140
- abstractNote: csl.abstract ?? "",
182
+ abstractNote: csl.abstract ? plainAbstract(csl.abstract) : "",
141
183
  ISBN: Array.isArray(csl.ISBN) ? csl.ISBN[0] : csl.ISBN ?? "",
142
184
  ISSN: Array.isArray(csl.ISSN) ? csl.ISSN[0] : csl.ISSN ?? "",
143
185
  edition: csl.edition ?? "",
144
186
  numPages: csl["number-of-pages"] ?? "",
145
187
  series: csl["collection-title"] ?? "",
146
188
  language: csl.language ?? "",
189
+ journalAbbreviation: csl["container-title-short"] ?? "",
190
+ conferenceName: csl["event-title"] ?? csl.event ?? "",
147
191
  };
192
+ if (numberField)
193
+ candidates[numberField] = number;
194
+ const publisherField = PUBLISHER_FIELD[itemType];
195
+ if (publisherField)
196
+ candidates[publisherField] = arxiv ? "arXiv" : (csl.publisher ?? "");
197
+ if (arxiv)
198
+ candidates.archiveID = `arXiv:${arxiv[1]}`;
148
199
  // Map container-title to the correct field for this item type
149
200
  const containerTitleField = CONTAINER_TITLE_FIELD[itemType];
150
201
  if (containerTitleField) {
@@ -168,35 +219,64 @@ export function cslToZoteroItem(csl, options) {
168
219
  }
169
220
  return result;
170
221
  }
222
+ /** Zotero creator → CSL name: a single-field name (institution) is a CSL `literal`. */
223
+ function cslName(c) {
224
+ if (c.name && !c.lastName)
225
+ return { literal: c.name };
226
+ return { family: c.lastName ?? "", given: c.firstName ?? "" };
227
+ }
228
+ /** CSL title of the container, from the item type's own field (bookTitle, proceedingsTitle, ...) or publicationTitle. */
229
+ function containerTitle(item) {
230
+ const field = CONTAINER_TITLE_FIELD[item.itemType];
231
+ const fields = item;
232
+ const value = (field && fields[field]) || item.publicationTitle;
233
+ return typeof value === "string" && value ? value : undefined;
234
+ }
235
+ /**
236
+ * CSL date of a Zotero date: "YYYY-MM-DD" (or a prefix of it) as date-parts; a date that does not start that way
237
+ * ("October 2023", "Spring 2023") as `raw`, which citeproc parses itself, instead of being dropped.
238
+ */
239
+ export function cslDate(value) {
240
+ if (!value)
241
+ return undefined;
242
+ const parts = value.split("-").map(Number).filter((n) => !isNaN(n));
243
+ return parts.length > 0 ? { "date-parts": [parts] } : { raw: value };
244
+ }
171
245
  export function zoteroItemToCsl(item) {
172
- const authors = (item.creators ?? [])
173
- .filter((c) => c.creatorType === "author")
174
- .map((c) => ({
175
- family: c.lastName ?? c.name ?? "",
176
- given: c.firstName ?? "",
177
- }));
178
- let issued;
179
- if (item.date) {
180
- const parts = item.date.split("-").map(Number).filter((n) => !isNaN(n));
181
- if (parts.length > 0) {
182
- issued = { "date-parts": [parts] };
183
- }
246
+ const names = {};
247
+ for (const [variable, creatorType] of CSL_NAME_CREATOR_TYPES) {
248
+ const list = (item.creators ?? []).filter((c) => c.creatorType === creatorType).map(cslName);
249
+ if (list.length > 0)
250
+ names[variable] = list;
184
251
  }
185
252
  // The Zotero API returns every field of the item type, empty ones as "": keep them out of the field code
186
- const text = (v) => (v ? v : undefined);
253
+ const fields = item;
254
+ const text = (...values) => values.find((v) => typeof v === "string" && v !== "") ?? undefined;
255
+ // accessDate is "YYYY-MM-DD hh:mm:ss" or ISO: only the day
256
+ const accessDate = text(fields.accessDate)?.replace(/[T ].*$/, "");
187
257
  return {
188
258
  type: ZOTERO_TO_CSL_TYPE[item.itemType ?? ""] ?? "article-journal",
189
259
  title: text(item.title),
190
- author: authors.length > 0 ? authors : undefined,
191
- issued,
260
+ ...names,
261
+ issued: cslDate(item.date),
192
262
  DOI: text(item.DOI),
193
- "container-title": text(item.publicationTitle),
263
+ "container-title": containerTitle(item),
194
264
  volume: text(item.volume),
195
265
  issue: text(item.issue),
196
266
  page: text(item.pages),
197
- publisher: text(item.publisher),
267
+ // Zotero's base fields: university (thesis), institution (report), repository (preprint) are the publisher;
268
+ // reportNumber and archiveID the number; thesisType and reportType the genre
269
+ publisher: text(item.publisher, fields.university, fields.institution, fields.repository),
198
270
  "publisher-place": text(item.place),
271
+ number: text(fields.reportNumber, fields.archiveID),
272
+ genre: text(fields.thesisType, fields.reportType),
199
273
  URL: text(item.url),
274
+ accessed: cslDate(accessDate),
275
+ ISBN: text(fields.ISBN),
276
+ ISSN: text(fields.ISSN),
277
+ edition: text(fields.edition),
278
+ language: text(fields.language),
279
+ "number-of-pages": text(fields.numPages),
200
280
  // abstract deliberately omitted: it bloats every field code (same choice as the skill's inject.js)
201
281
  };
202
282
  }
@@ -2,6 +2,7 @@ import { mapWithConcurrency } from "./concurrency.js";
2
2
  import { fetchWithRetry } from "./fetch-retry.js";
3
3
  import { errorMessage } from "./error-formatter.js";
4
4
  import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
5
+ import { decodeEntitiesDeep } from "./html-entities.js";
5
6
  export async function resolveDoi(doi) {
6
7
  const response = await fetchWithRetry(`https://doi.org/${encodeURIComponent(doi)}`, {
7
8
  headers: {
@@ -10,10 +11,17 @@ export async function resolveDoi(doi) {
10
11
  redirect: "follow",
11
12
  }, { label: "DOI resolution" });
12
13
  if (!response.ok) {
13
- throw new Error(`DOI resolution failed for ${doi}: ${response.status} ${response.statusText}`);
14
+ const why = response.status === 404
15
+ ? " (DOI not registered or mistyped)"
16
+ : response.status === 429 || response.status >= 500
17
+ ? " (doi.org or the registry is busy or failing: retry later)"
18
+ : "";
19
+ const status = [response.status, response.statusText].filter(Boolean).join(" ");
20
+ throw new Error(`DOI resolution failed for ${doi}: ${status}${why}`);
14
21
  }
15
22
  try {
16
- return await readJsonObject(response);
23
+ // the registries send some strings HTML-escaped ("Computers &amp; Operations Research"): store the characters
24
+ return decodeEntitiesDeep(await readJsonObject(response));
17
25
  }
18
26
  catch (err) {
19
27
  // doi.org answers some unresolvable DOIs with an HTML page and status 200