@xevos117/mcp-zotero 2.0.3 → 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.
- package/build/tools/delete-collection.js +34 -21
- package/build/tools/delete-items.js +56 -40
- package/build/utils/csl-to-zotero.js +69 -14
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { isZoteroApiError } from "../types/zotero-types.js";
|
|
|
4
4
|
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
5
5
|
import { canDeleteCollections } from "../utils/unsafe-operations.js";
|
|
6
6
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
|
-
import { COLLECTION_KEY_HINT, unsafeOperationsHint } from "../utils/error-hints.js";
|
|
7
|
+
import { COLLECTION_KEY_HINT, unsafeOperationsHint, zoteroStatusHint } from "../utils/error-hints.js";
|
|
8
8
|
export const toolConfig = {
|
|
9
9
|
name: "delete_collection",
|
|
10
10
|
description: "Delete a collection (folder) from your Zotero library; confirm with the user first. Items inside the collection are NOT deleted — they remain in your library. Disabled unless the server setting UNSAFE_OPERATIONS is 'all'.",
|
|
@@ -27,28 +27,38 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
for (let attempt = 1;; attempt++) {
|
|
31
|
+
const response = await zoteroApi
|
|
32
|
+
.library(libraryType, libraryId)
|
|
33
|
+
.collections(collection_key)
|
|
34
|
+
.get();
|
|
35
|
+
const collection = response.getData();
|
|
36
|
+
const version = response.getVersion();
|
|
37
|
+
if (version === null) {
|
|
38
|
+
return formatErrorResponse("Could not determine collection version", {
|
|
39
|
+
collection_key,
|
|
40
|
+
hint: "Nothing was deleted. Retry the same call once.",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await zoteroApi
|
|
45
|
+
.library(libraryType, libraryId)
|
|
46
|
+
.collections(collection_key)
|
|
47
|
+
.version(version)
|
|
48
|
+
.delete();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
// 412: the collection changed between the GET and the DELETE; read it again and retry once
|
|
52
|
+
if (attempt === 1 && isZoteroApiError(err) && err.response.status === 412)
|
|
53
|
+
continue;
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
return formatJsonResult({
|
|
57
|
+
deleted: true,
|
|
38
58
|
collection_key,
|
|
39
|
-
|
|
59
|
+
name: collection.name || collection_key,
|
|
40
60
|
});
|
|
41
61
|
}
|
|
42
|
-
await zoteroApi
|
|
43
|
-
.library(libraryType, libraryId)
|
|
44
|
-
.collections(collection_key)
|
|
45
|
-
.version(version)
|
|
46
|
-
.delete();
|
|
47
|
-
return formatJsonResult({
|
|
48
|
-
deleted: true,
|
|
49
|
-
collection_key,
|
|
50
|
-
name: collection.name || collection_key,
|
|
51
|
-
});
|
|
52
62
|
}
|
|
53
63
|
catch (err) {
|
|
54
64
|
if (isZoteroApiError(err)) {
|
|
@@ -60,9 +70,12 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
|
|
|
60
70
|
});
|
|
61
71
|
}
|
|
62
72
|
if (err.response.status === 412) {
|
|
63
|
-
|
|
73
|
+
const { hint } = zoteroStatusHint(412);
|
|
74
|
+
return formatErrorResponse("Collection was modified by another client (HTTP 412 Precondition Failed), also on a second try with its new version, so it was not deleted.", {
|
|
64
75
|
collection_key,
|
|
65
76
|
status: "version_conflict",
|
|
77
|
+
http_status: 412,
|
|
78
|
+
hint,
|
|
66
79
|
});
|
|
67
80
|
}
|
|
68
81
|
}
|
|
@@ -5,7 +5,7 @@ import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.
|
|
|
5
5
|
import { canDeleteItems } from "../utils/unsafe-operations.js";
|
|
6
6
|
import { fetchItemsByKeys } from "../utils/pagination.js";
|
|
7
7
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
8
|
-
import { ITEM_KEY_HINT, unsafeOperationsHint } from "../utils/error-hints.js";
|
|
8
|
+
import { ITEM_KEY_HINT, unsafeOperationsHint, zoteroStatusHint } from "../utils/error-hints.js";
|
|
9
9
|
export const toolConfig = {
|
|
10
10
|
name: "delete_items",
|
|
11
11
|
description: "Delete one or more items from your Zotero library: they are permanently deleted (not moved to the Zotero trash), so confirm with the user first. Accepts up to 50 item keys per call. Disabled unless the server setting UNSAFE_OPERATIONS is 'items' or 'all'.",
|
|
@@ -31,48 +31,64 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
|
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
// only keys the caller asked for, whatever the API returned
|
|
53
|
-
const keysToDelete = item_keys.filter((k) => foundKeys.has(k));
|
|
54
|
-
await zoteroApi
|
|
55
|
-
.library(libraryType, libraryId)
|
|
56
|
-
.items()
|
|
57
|
-
.version(libraryVersion)
|
|
58
|
-
.delete(keysToDelete);
|
|
59
|
-
const result = {
|
|
60
|
-
deleted_keys: keysToDelete,
|
|
61
|
-
deleted_count: keysToDelete.length,
|
|
62
|
-
};
|
|
63
|
-
if (notFoundKeys.length > 0) {
|
|
64
|
-
result.not_found = notFoundKeys;
|
|
65
|
-
}
|
|
66
|
-
return formatJsonResult(result);
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
if (isZoteroApiError(err)) {
|
|
70
|
-
if (err.response.status === 412) {
|
|
71
|
-
return formatErrorResponse("Items were modified by another client, so nothing was deleted. Retry the same call.", {
|
|
34
|
+
const notFoundKeys = [];
|
|
35
|
+
let pending = item_keys;
|
|
36
|
+
for (let attempt = 1;; attempt++) {
|
|
37
|
+
const { items, version: libraryVersion } = await fetchItemsByKeys((params) => zoteroApi.library(libraryType, libraryId).items().get(params), pending);
|
|
38
|
+
const foundKeys = new Set(items.map((item) => item.key).filter(Boolean));
|
|
39
|
+
notFoundKeys.push(...pending.filter((k) => !foundKeys.has(k)));
|
|
40
|
+
if (foundKeys.size === 0) {
|
|
41
|
+
return formatErrorResponse("No items found for the given keys", {
|
|
42
|
+
item_keys,
|
|
43
|
+
status: "not_found",
|
|
44
|
+
hint: `Nothing was deleted: none of these keys exists in this library (already deleted, or in another library). ${ITEM_KEY_HINT}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// Use library version from response header (Last-Modified-Version),
|
|
48
|
+
// not individual item versions — required for multi-object DELETE
|
|
49
|
+
if (libraryVersion === null) {
|
|
50
|
+
return formatErrorResponse("Could not determine library version", {
|
|
72
51
|
item_keys,
|
|
73
|
-
|
|
52
|
+
hint: "Nothing was deleted. Retry the same call once.",
|
|
74
53
|
});
|
|
75
54
|
}
|
|
55
|
+
// only keys the caller asked for, whatever the API returned
|
|
56
|
+
const keysToDelete = pending.filter((k) => foundKeys.has(k));
|
|
57
|
+
try {
|
|
58
|
+
await zoteroApi
|
|
59
|
+
.library(libraryType, libraryId)
|
|
60
|
+
.items()
|
|
61
|
+
.version(libraryVersion)
|
|
62
|
+
.delete(keysToDelete);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// 412: the library changed between the GET and the DELETE (another client, a sync). Read the new
|
|
66
|
+
// version and the keys that still exist, and retry once with those.
|
|
67
|
+
if (attempt === 1 && isZoteroApiError(err) && err.response.status === 412) {
|
|
68
|
+
pending = keysToDelete;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
const result = {
|
|
74
|
+
deleted_keys: keysToDelete,
|
|
75
|
+
deleted_count: keysToDelete.length,
|
|
76
|
+
};
|
|
77
|
+
if (notFoundKeys.length > 0) {
|
|
78
|
+
result.not_found = notFoundKeys;
|
|
79
|
+
}
|
|
80
|
+
return formatJsonResult(result);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (isZoteroApiError(err) && err.response.status === 412) {
|
|
85
|
+
const { hint } = zoteroStatusHint(412);
|
|
86
|
+
return formatErrorResponse("Items were modified by another client (HTTP 412 Precondition Failed), also on a second try with the new library version, so nothing was deleted.", {
|
|
87
|
+
item_keys,
|
|
88
|
+
status: "version_conflict",
|
|
89
|
+
http_status: 412,
|
|
90
|
+
hint,
|
|
91
|
+
});
|
|
76
92
|
}
|
|
77
93
|
throw err;
|
|
78
94
|
}
|
|
@@ -123,8 +123,33 @@ const CSL_NAME_CREATOR_TYPES = [
|
|
|
123
123
|
["collection-editor", "seriesEditor"],
|
|
124
124
|
["translator", "translator"],
|
|
125
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
|
+
}
|
|
126
150
|
export function cslToZoteroItem(csl, options) {
|
|
127
|
-
const
|
|
151
|
+
const arxiv = ARXIV_DOI.exec(csl.DOI ?? "");
|
|
152
|
+
const itemType = (arxiv ? "preprint" : (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle"));
|
|
128
153
|
// Authors always (as before); other roles only where the item type has them, or the API rejects the item.
|
|
129
154
|
// A CSL `literal` (institution, consortium) is Zotero's single-field name
|
|
130
155
|
const validCreatorTypes = ITEM_TYPE_CREATOR_TYPES[itemType];
|
|
@@ -138,25 +163,39 @@ export function cslToZoteroItem(csl, options) {
|
|
|
138
163
|
collections.push(options.collectionKey);
|
|
139
164
|
}
|
|
140
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];
|
|
141
171
|
// Build candidate fields (CSL → Zotero field names)
|
|
142
172
|
const candidates = {
|
|
143
|
-
title
|
|
173
|
+
title,
|
|
144
174
|
date,
|
|
145
175
|
DOI: csl.DOI ?? "",
|
|
146
176
|
volume: csl.volume ?? "",
|
|
147
177
|
issue: csl.issue ?? "",
|
|
148
|
-
pages: csl.page ?? "",
|
|
178
|
+
pages: csl.page ?? (numberField ? "" : (csl["article-number"] ?? "")),
|
|
149
179
|
publisher: csl.publisher ?? "",
|
|
150
180
|
place: csl["publisher-place"] ?? "",
|
|
151
181
|
url: csl.URL ?? "",
|
|
152
|
-
abstractNote: csl.abstract
|
|
182
|
+
abstractNote: csl.abstract ? plainAbstract(csl.abstract) : "",
|
|
153
183
|
ISBN: Array.isArray(csl.ISBN) ? csl.ISBN[0] : csl.ISBN ?? "",
|
|
154
184
|
ISSN: Array.isArray(csl.ISSN) ? csl.ISSN[0] : csl.ISSN ?? "",
|
|
155
185
|
edition: csl.edition ?? "",
|
|
156
186
|
numPages: csl["number-of-pages"] ?? "",
|
|
157
187
|
series: csl["collection-title"] ?? "",
|
|
158
188
|
language: csl.language ?? "",
|
|
189
|
+
journalAbbreviation: csl["container-title-short"] ?? "",
|
|
190
|
+
conferenceName: csl["event-title"] ?? csl.event ?? "",
|
|
159
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]}`;
|
|
160
199
|
// Map container-title to the correct field for this item type
|
|
161
200
|
const containerTitleField = CONTAINER_TITLE_FIELD[itemType];
|
|
162
201
|
if (containerTitleField) {
|
|
@@ -193,6 +232,16 @@ function containerTitle(item) {
|
|
|
193
232
|
const value = (field && fields[field]) || item.publicationTitle;
|
|
194
233
|
return typeof value === "string" && value ? value : undefined;
|
|
195
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
|
+
}
|
|
196
245
|
export function zoteroItemToCsl(item) {
|
|
197
246
|
const names = {};
|
|
198
247
|
for (const [variable, creatorType] of CSL_NAME_CREATOR_TYPES) {
|
|
@@ -200,28 +249,34 @@ export function zoteroItemToCsl(item) {
|
|
|
200
249
|
if (list.length > 0)
|
|
201
250
|
names[variable] = list;
|
|
202
251
|
}
|
|
203
|
-
let issued;
|
|
204
|
-
if (item.date) {
|
|
205
|
-
const parts = item.date.split("-").map(Number).filter((n) => !isNaN(n));
|
|
206
|
-
if (parts.length > 0) {
|
|
207
|
-
issued = { "date-parts": [parts] };
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
252
|
// The Zotero API returns every field of the item type, empty ones as "": keep them out of the field code
|
|
211
|
-
const
|
|
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 ].*$/, "");
|
|
212
257
|
return {
|
|
213
258
|
type: ZOTERO_TO_CSL_TYPE[item.itemType ?? ""] ?? "article-journal",
|
|
214
259
|
title: text(item.title),
|
|
215
260
|
...names,
|
|
216
|
-
issued,
|
|
261
|
+
issued: cslDate(item.date),
|
|
217
262
|
DOI: text(item.DOI),
|
|
218
263
|
"container-title": containerTitle(item),
|
|
219
264
|
volume: text(item.volume),
|
|
220
265
|
issue: text(item.issue),
|
|
221
266
|
page: text(item.pages),
|
|
222
|
-
|
|
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),
|
|
223
270
|
"publisher-place": text(item.place),
|
|
271
|
+
number: text(fields.reportNumber, fields.archiveID),
|
|
272
|
+
genre: text(fields.thesisType, fields.reportType),
|
|
224
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),
|
|
225
280
|
// abstract deliberately omitted: it bloats every field code (same choice as the skill's inject.js)
|
|
226
281
|
};
|
|
227
282
|
}
|
package/package.json
CHANGED