@ncukondo/reference-manager 0.3.0 → 0.5.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 +282 -107
- package/dist/chunks/file-watcher-B-SiUw5f.js +1557 -0
- package/dist/chunks/file-watcher-B-SiUw5f.js.map +1 -0
- package/dist/chunks/{search-Be9vzUIH.js → index-DLIGxQaB.js} +399 -89
- package/dist/chunks/index-DLIGxQaB.js.map +1 -0
- package/dist/chunks/loader-DuzyKV70.js +394 -0
- package/dist/chunks/loader-DuzyKV70.js.map +1 -0
- package/dist/cli/commands/add.d.ts +3 -3
- package/dist/cli/commands/add.d.ts.map +1 -1
- package/dist/cli/commands/cite.d.ts +3 -3
- package/dist/cli/commands/cite.d.ts.map +1 -1
- package/dist/cli/commands/fulltext.d.ts +5 -34
- package/dist/cli/commands/fulltext.d.ts.map +1 -1
- package/dist/cli/commands/list.d.ts +3 -3
- package/dist/cli/commands/list.d.ts.map +1 -1
- package/dist/cli/commands/mcp.d.ts +16 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/remove.d.ts +3 -3
- package/dist/cli/commands/remove.d.ts.map +1 -1
- package/dist/cli/commands/search.d.ts +3 -3
- package/dist/cli/commands/search.d.ts.map +1 -1
- package/dist/cli/commands/server.d.ts +2 -0
- package/dist/cli/commands/server.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts +3 -3
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/execution-context.d.ts +23 -36
- package/dist/cli/execution-context.d.ts.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/server-client.d.ts +24 -40
- package/dist/cli/server-client.d.ts.map +1 -1
- package/dist/cli/server-detection.d.ts +1 -0
- package/dist/cli/server-detection.d.ts.map +1 -1
- package/dist/cli.js +21061 -317
- package/dist/cli.js.map +1 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/schema.d.ts +2 -3
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/core/csl-json/types.d.ts +3 -0
- package/dist/core/csl-json/types.d.ts.map +1 -1
- package/dist/core/library-interface.d.ts +100 -0
- package/dist/core/library-interface.d.ts.map +1 -0
- package/dist/core/library.d.ts +29 -46
- package/dist/core/library.d.ts.map +1 -1
- package/dist/features/operations/add.d.ts +2 -2
- package/dist/features/operations/add.d.ts.map +1 -1
- package/dist/features/operations/cite.d.ts +2 -2
- package/dist/features/operations/cite.d.ts.map +1 -1
- package/dist/features/operations/fulltext/attach.d.ts +47 -0
- package/dist/features/operations/fulltext/attach.d.ts.map +1 -0
- package/dist/features/operations/fulltext/detach.d.ts +38 -0
- package/dist/features/operations/fulltext/detach.d.ts.map +1 -0
- package/dist/features/operations/fulltext/get.d.ts +41 -0
- package/dist/features/operations/fulltext/get.d.ts.map +1 -0
- package/dist/features/operations/fulltext/index.d.ts +9 -0
- package/dist/features/operations/fulltext/index.d.ts.map +1 -0
- package/dist/features/operations/index.d.ts +15 -0
- package/dist/features/operations/index.d.ts.map +1 -0
- package/dist/features/operations/library-operations.d.ts +64 -0
- package/dist/features/operations/library-operations.d.ts.map +1 -0
- package/dist/features/operations/list.d.ts +2 -2
- package/dist/features/operations/list.d.ts.map +1 -1
- package/dist/features/operations/operations-library.d.ts +36 -0
- package/dist/features/operations/operations-library.d.ts.map +1 -0
- package/dist/features/operations/remove.d.ts +4 -4
- package/dist/features/operations/remove.d.ts.map +1 -1
- package/dist/features/operations/search.d.ts +2 -2
- package/dist/features/operations/search.d.ts.map +1 -1
- package/dist/features/operations/update.d.ts +2 -2
- package/dist/features/operations/update.d.ts.map +1 -1
- package/dist/features/search/matcher.d.ts.map +1 -1
- package/dist/features/search/normalizer.d.ts +12 -0
- package/dist/features/search/normalizer.d.ts.map +1 -1
- package/dist/features/search/tokenizer.d.ts.map +1 -1
- package/dist/features/search/types.d.ts +1 -1
- package/dist/features/search/types.d.ts.map +1 -1
- package/dist/features/search/uppercase.d.ts +41 -0
- package/dist/features/search/uppercase.d.ts.map +1 -0
- package/dist/index.js +24 -192
- package/dist/index.js.map +1 -1
- package/dist/mcp/context.d.ts +19 -0
- package/dist/mcp/context.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +20 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/resources/index.d.ts +10 -0
- package/dist/mcp/resources/index.d.ts.map +1 -0
- package/dist/mcp/resources/library.d.ts +26 -0
- package/dist/mcp/resources/library.d.ts.map +1 -0
- package/dist/mcp/tools/add.d.ts +17 -0
- package/dist/mcp/tools/add.d.ts.map +1 -0
- package/dist/mcp/tools/cite.d.ts +15 -0
- package/dist/mcp/tools/cite.d.ts.map +1 -0
- package/dist/mcp/tools/fulltext.d.ts +51 -0
- package/dist/mcp/tools/fulltext.d.ts.map +1 -0
- package/dist/mcp/tools/index.d.ts +12 -0
- package/dist/mcp/tools/index.d.ts.map +1 -0
- package/dist/mcp/tools/list.d.ts +13 -0
- package/dist/mcp/tools/list.d.ts.map +1 -0
- package/dist/mcp/tools/remove.d.ts +19 -0
- package/dist/mcp/tools/remove.d.ts.map +1 -0
- package/dist/mcp/tools/search.d.ts +13 -0
- package/dist/mcp/tools/search.d.ts.map +1 -0
- package/dist/server/index.d.ts +23 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/routes/references.d.ts.map +1 -1
- package/dist/server.js +5 -271
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
- package/dist/chunks/detector-DHztTaFY.js +0 -619
- package/dist/chunks/detector-DHztTaFY.js.map +0 -1
- package/dist/chunks/loader-mQ25o6cV.js +0 -1054
- package/dist/chunks/loader-mQ25o6cV.js.map +0 -1
- package/dist/chunks/search-Be9vzUIH.js.map +0 -1
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
import chokidar from "chokidar";
|
|
10
|
+
function normalizeText(text) {
|
|
11
|
+
return text.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
12
|
+
}
|
|
13
|
+
function normalizeAuthorName(name) {
|
|
14
|
+
const normalized = normalizeText(name);
|
|
15
|
+
return normalized.slice(0, 32);
|
|
16
|
+
}
|
|
17
|
+
function normalizeTitleSlug(title) {
|
|
18
|
+
const normalized = normalizeText(title);
|
|
19
|
+
return normalized.slice(0, 32);
|
|
20
|
+
}
|
|
21
|
+
function extractAuthorName(item) {
|
|
22
|
+
if (!item.author || item.author.length === 0) {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
const firstAuthor = item.author[0];
|
|
26
|
+
if (!firstAuthor) {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
if (firstAuthor.family) {
|
|
30
|
+
return normalizeAuthorName(firstAuthor.family);
|
|
31
|
+
}
|
|
32
|
+
if (firstAuthor.literal) {
|
|
33
|
+
return normalizeAuthorName(firstAuthor.literal);
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
function extractYear$3(item) {
|
|
38
|
+
if (!item.issued || !item.issued["date-parts"] || item.issued["date-parts"].length === 0) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
const dateParts = item.issued["date-parts"][0];
|
|
42
|
+
if (!dateParts || dateParts.length === 0) {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
const year = dateParts[0];
|
|
46
|
+
return year ? year.toString() : "";
|
|
47
|
+
}
|
|
48
|
+
function determineTitlePart(hasAuthor, hasYear, title) {
|
|
49
|
+
if (hasAuthor && hasYear) {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
if (title) {
|
|
53
|
+
return `-${title}`;
|
|
54
|
+
}
|
|
55
|
+
if (!hasAuthor && !hasYear) {
|
|
56
|
+
return "-untitled";
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
function generateId(item) {
|
|
61
|
+
const author = extractAuthorName(item);
|
|
62
|
+
const year = extractYear$3(item);
|
|
63
|
+
const title = item.title ? normalizeTitleSlug(item.title) : "";
|
|
64
|
+
const authorPart = author || "anon";
|
|
65
|
+
const yearPart = year || "nd";
|
|
66
|
+
const titlePart = determineTitlePart(Boolean(author), Boolean(year), title);
|
|
67
|
+
return `${authorPart}-${yearPart}${titlePart}`;
|
|
68
|
+
}
|
|
69
|
+
function generateSuffix(index) {
|
|
70
|
+
if (index === 0) {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
let suffix = "";
|
|
74
|
+
let num = index;
|
|
75
|
+
while (num > 0) {
|
|
76
|
+
num--;
|
|
77
|
+
suffix = String.fromCharCode(97 + num % 26) + suffix;
|
|
78
|
+
num = Math.floor(num / 26);
|
|
79
|
+
}
|
|
80
|
+
return suffix;
|
|
81
|
+
}
|
|
82
|
+
function generateIdWithCollisionCheck(item, existingIds) {
|
|
83
|
+
const baseId = generateId(item);
|
|
84
|
+
const normalizedExistingIds = existingIds.map((id) => id.toLowerCase());
|
|
85
|
+
let candidate = baseId;
|
|
86
|
+
let suffixIndex = 0;
|
|
87
|
+
while (normalizedExistingIds.includes(candidate.toLowerCase())) {
|
|
88
|
+
suffixIndex++;
|
|
89
|
+
const suffix = generateSuffix(suffixIndex);
|
|
90
|
+
candidate = `${baseId}${suffix}`;
|
|
91
|
+
}
|
|
92
|
+
return candidate;
|
|
93
|
+
}
|
|
94
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
95
|
+
function isValidUuid(uuid) {
|
|
96
|
+
return UUID_REGEX.test(uuid);
|
|
97
|
+
}
|
|
98
|
+
function generateUuid() {
|
|
99
|
+
return randomUUID();
|
|
100
|
+
}
|
|
101
|
+
function generateTimestamp() {
|
|
102
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
103
|
+
}
|
|
104
|
+
function extractUuidFromCustom(custom) {
|
|
105
|
+
if (!custom || !custom.uuid) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return isValidUuid(custom.uuid) ? custom.uuid : null;
|
|
109
|
+
}
|
|
110
|
+
function ensureUuid(custom) {
|
|
111
|
+
const existingUuid = extractUuidFromCustom(custom);
|
|
112
|
+
if (existingUuid && custom) {
|
|
113
|
+
return custom;
|
|
114
|
+
}
|
|
115
|
+
const newUuid = generateUuid();
|
|
116
|
+
return {
|
|
117
|
+
...custom,
|
|
118
|
+
uuid: newUuid
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function ensureCreatedAt(custom) {
|
|
122
|
+
if (custom.created_at) {
|
|
123
|
+
return custom;
|
|
124
|
+
}
|
|
125
|
+
if (custom.timestamp) {
|
|
126
|
+
return {
|
|
127
|
+
...custom,
|
|
128
|
+
created_at: custom.timestamp
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const newTimestamp = generateTimestamp();
|
|
132
|
+
return {
|
|
133
|
+
...custom,
|
|
134
|
+
created_at: newTimestamp
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function ensureTimestamp(custom) {
|
|
138
|
+
if (custom.timestamp) {
|
|
139
|
+
return custom;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...custom,
|
|
143
|
+
timestamp: custom.created_at
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function ensureCustomMetadata(custom) {
|
|
147
|
+
const withUuid = ensureUuid(custom);
|
|
148
|
+
const withCreatedAt = ensureCreatedAt(withUuid);
|
|
149
|
+
const withTimestamp = ensureTimestamp(withCreatedAt);
|
|
150
|
+
return withTimestamp;
|
|
151
|
+
}
|
|
152
|
+
class Reference {
|
|
153
|
+
item;
|
|
154
|
+
uuid;
|
|
155
|
+
constructor(item) {
|
|
156
|
+
const customMetadata = ensureCustomMetadata(item.custom);
|
|
157
|
+
this.item = { ...item, custom: customMetadata };
|
|
158
|
+
const extractedUuid = extractUuidFromCustom(customMetadata);
|
|
159
|
+
if (!extractedUuid) {
|
|
160
|
+
throw new Error("Failed to extract UUID after ensureCustomMetadata");
|
|
161
|
+
}
|
|
162
|
+
this.uuid = extractedUuid;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Factory method to create a Reference with UUID and ID generation
|
|
166
|
+
*/
|
|
167
|
+
static create(item, options) {
|
|
168
|
+
const existingIds = options?.existingIds || /* @__PURE__ */ new Set();
|
|
169
|
+
let updatedItem = item;
|
|
170
|
+
if (!item.id || item.id.trim() === "") {
|
|
171
|
+
const generatedId = generateIdWithCollisionCheck(item, Array.from(existingIds));
|
|
172
|
+
updatedItem = { ...item, id: generatedId };
|
|
173
|
+
}
|
|
174
|
+
return new Reference(updatedItem);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Get the underlying CSL-JSON item
|
|
178
|
+
*/
|
|
179
|
+
getItem() {
|
|
180
|
+
return this.item;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Get the UUID (internal stable identifier)
|
|
184
|
+
*/
|
|
185
|
+
getUuid() {
|
|
186
|
+
return this.uuid;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Get the ID (Pandoc citation key / BibTeX-key)
|
|
190
|
+
*/
|
|
191
|
+
getId() {
|
|
192
|
+
return this.item.id;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get the title
|
|
196
|
+
*/
|
|
197
|
+
getTitle() {
|
|
198
|
+
return this.item.title;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get the authors
|
|
202
|
+
*/
|
|
203
|
+
getAuthors() {
|
|
204
|
+
return this.item.author;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Get the year from issued date
|
|
208
|
+
*/
|
|
209
|
+
getYear() {
|
|
210
|
+
const issued = this.item.issued;
|
|
211
|
+
if (!issued || !issued["date-parts"] || issued["date-parts"].length === 0) {
|
|
212
|
+
return void 0;
|
|
213
|
+
}
|
|
214
|
+
const firstDate = issued["date-parts"][0];
|
|
215
|
+
return firstDate && firstDate.length > 0 ? firstDate[0] : void 0;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Get the DOI
|
|
219
|
+
*/
|
|
220
|
+
getDoi() {
|
|
221
|
+
return this.item.DOI;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Get the PMID
|
|
225
|
+
*/
|
|
226
|
+
getPmid() {
|
|
227
|
+
return this.item.PMID;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get the PMCID
|
|
231
|
+
*/
|
|
232
|
+
getPmcid() {
|
|
233
|
+
return this.item.PMCID;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Get the URL
|
|
237
|
+
*/
|
|
238
|
+
getUrl() {
|
|
239
|
+
return this.item.URL;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Get the keyword
|
|
243
|
+
*/
|
|
244
|
+
getKeyword() {
|
|
245
|
+
return this.item.keyword;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Get additional URLs from custom metadata
|
|
249
|
+
*/
|
|
250
|
+
getAdditionalUrls() {
|
|
251
|
+
return this.item.custom?.additional_urls;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Get the creation timestamp from custom metadata (immutable)
|
|
255
|
+
*/
|
|
256
|
+
getCreatedAt() {
|
|
257
|
+
if (!this.item.custom?.created_at) {
|
|
258
|
+
throw new Error("created_at is missing from custom metadata");
|
|
259
|
+
}
|
|
260
|
+
return this.item.custom.created_at;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get the last modification timestamp from custom metadata
|
|
264
|
+
*/
|
|
265
|
+
getTimestamp() {
|
|
266
|
+
if (!this.item.custom?.timestamp) {
|
|
267
|
+
throw new Error("timestamp is missing from custom metadata");
|
|
268
|
+
}
|
|
269
|
+
return this.item.custom.timestamp;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Update the timestamp to current time
|
|
273
|
+
* Call this whenever the reference is modified
|
|
274
|
+
*/
|
|
275
|
+
touch() {
|
|
276
|
+
if (!this.item.custom) {
|
|
277
|
+
throw new Error("custom metadata is missing");
|
|
278
|
+
}
|
|
279
|
+
this.item.custom.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Get the type
|
|
283
|
+
*/
|
|
284
|
+
getType() {
|
|
285
|
+
return this.item.type;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function computeHash(input) {
|
|
289
|
+
return createHash("sha256").update(input, "utf-8").digest("hex");
|
|
290
|
+
}
|
|
291
|
+
async function computeFileHash(filePath) {
|
|
292
|
+
return new Promise((resolve, reject) => {
|
|
293
|
+
const hash = createHash("sha256");
|
|
294
|
+
const stream = createReadStream(filePath);
|
|
295
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
296
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
297
|
+
stream.on("error", (error) => reject(error));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const CslNameSchema = z.object({
|
|
301
|
+
family: z.string().optional(),
|
|
302
|
+
given: z.string().optional(),
|
|
303
|
+
literal: z.string().optional(),
|
|
304
|
+
"dropping-particle": z.string().optional(),
|
|
305
|
+
"non-dropping-particle": z.string().optional(),
|
|
306
|
+
suffix: z.string().optional()
|
|
307
|
+
});
|
|
308
|
+
const CslDateSchema = z.object({
|
|
309
|
+
"date-parts": z.array(z.array(z.number())).optional(),
|
|
310
|
+
raw: z.string().optional(),
|
|
311
|
+
season: z.string().optional(),
|
|
312
|
+
circa: z.boolean().optional(),
|
|
313
|
+
literal: z.string().optional()
|
|
314
|
+
});
|
|
315
|
+
const CslFulltextSchema = z.object({
|
|
316
|
+
pdf: z.string().optional(),
|
|
317
|
+
markdown: z.string().optional()
|
|
318
|
+
});
|
|
319
|
+
const CslCustomSchema = z.object({
|
|
320
|
+
uuid: z.string(),
|
|
321
|
+
created_at: z.string(),
|
|
322
|
+
timestamp: z.string(),
|
|
323
|
+
additional_urls: z.array(z.string()).optional(),
|
|
324
|
+
fulltext: CslFulltextSchema.optional(),
|
|
325
|
+
tags: z.array(z.string()).optional()
|
|
326
|
+
}).passthrough();
|
|
327
|
+
const CslItemSchema = z.object({
|
|
328
|
+
id: z.string(),
|
|
329
|
+
type: z.string(),
|
|
330
|
+
title: z.string().optional(),
|
|
331
|
+
author: z.array(CslNameSchema).optional(),
|
|
332
|
+
editor: z.array(CslNameSchema).optional(),
|
|
333
|
+
issued: CslDateSchema.optional(),
|
|
334
|
+
accessed: CslDateSchema.optional(),
|
|
335
|
+
"container-title": z.string().optional(),
|
|
336
|
+
volume: z.string().optional(),
|
|
337
|
+
issue: z.string().optional(),
|
|
338
|
+
page: z.string().optional(),
|
|
339
|
+
DOI: z.string().optional(),
|
|
340
|
+
PMID: z.string().optional(),
|
|
341
|
+
PMCID: z.string().optional(),
|
|
342
|
+
ISBN: z.string().optional(),
|
|
343
|
+
ISSN: z.string().optional(),
|
|
344
|
+
URL: z.string().optional(),
|
|
345
|
+
abstract: z.string().optional(),
|
|
346
|
+
publisher: z.string().optional(),
|
|
347
|
+
"publisher-place": z.string().optional(),
|
|
348
|
+
note: z.string().optional(),
|
|
349
|
+
keyword: z.array(z.string()).optional(),
|
|
350
|
+
custom: CslCustomSchema.optional()
|
|
351
|
+
// Allow additional fields
|
|
352
|
+
}).passthrough();
|
|
353
|
+
const CslLibrarySchema = z.array(CslItemSchema);
|
|
354
|
+
function parseKeyword(keyword) {
|
|
355
|
+
if (typeof keyword !== "string") {
|
|
356
|
+
return void 0;
|
|
357
|
+
}
|
|
358
|
+
if (keyword.trim() === "") {
|
|
359
|
+
return void 0;
|
|
360
|
+
}
|
|
361
|
+
const keywords = keyword.split(";").map((k) => k.trim()).filter((k) => k !== "");
|
|
362
|
+
return keywords.length > 0 ? keywords : void 0;
|
|
363
|
+
}
|
|
364
|
+
async function parseCslJson(filePath) {
|
|
365
|
+
const content = await readFile(filePath, "utf-8");
|
|
366
|
+
let rawData;
|
|
367
|
+
try {
|
|
368
|
+
rawData = JSON.parse(content);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (Array.isArray(rawData)) {
|
|
375
|
+
rawData = rawData.map((item) => {
|
|
376
|
+
if (item && typeof item === "object" && "keyword" in item) {
|
|
377
|
+
const itemWithKeyword = item;
|
|
378
|
+
return {
|
|
379
|
+
...itemWithKeyword,
|
|
380
|
+
keyword: parseKeyword(itemWithKeyword.keyword)
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return item;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const parseResult = CslLibrarySchema.safeParse(rawData);
|
|
387
|
+
if (!parseResult.success) {
|
|
388
|
+
throw new Error(`Invalid CSL-JSON structure: ${parseResult.error.message}`);
|
|
389
|
+
}
|
|
390
|
+
const library = parseResult.data;
|
|
391
|
+
const processedLibrary = library.map((item) => {
|
|
392
|
+
const updatedCustom = ensureCustomMetadata(item.custom);
|
|
393
|
+
return {
|
|
394
|
+
...item,
|
|
395
|
+
custom: updatedCustom
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
return processedLibrary;
|
|
399
|
+
}
|
|
400
|
+
function serializeKeyword(keywords) {
|
|
401
|
+
if (!keywords || keywords.length === 0) {
|
|
402
|
+
return void 0;
|
|
403
|
+
}
|
|
404
|
+
return keywords.join("; ");
|
|
405
|
+
}
|
|
406
|
+
function serializeCslJson(library) {
|
|
407
|
+
const libraryForJson = library.map((item) => {
|
|
408
|
+
const { keyword, ...rest } = item;
|
|
409
|
+
const serializedKeyword = serializeKeyword(keyword);
|
|
410
|
+
if (serializedKeyword === void 0) {
|
|
411
|
+
return rest;
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
...rest,
|
|
415
|
+
keyword: serializedKeyword
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
return JSON.stringify(libraryForJson, null, 2);
|
|
419
|
+
}
|
|
420
|
+
async function writeCslJson(filePath, library) {
|
|
421
|
+
const dir = dirname(filePath);
|
|
422
|
+
await mkdir(dir, { recursive: true });
|
|
423
|
+
const content = serializeCslJson(library);
|
|
424
|
+
await writeFile(filePath, content, "utf-8");
|
|
425
|
+
}
|
|
426
|
+
class Library {
|
|
427
|
+
filePath;
|
|
428
|
+
references = [];
|
|
429
|
+
currentHash = null;
|
|
430
|
+
// Indices for fast lookup
|
|
431
|
+
uuidIndex = /* @__PURE__ */ new Map();
|
|
432
|
+
idIndex = /* @__PURE__ */ new Map();
|
|
433
|
+
doiIndex = /* @__PURE__ */ new Map();
|
|
434
|
+
pmidIndex = /* @__PURE__ */ new Map();
|
|
435
|
+
constructor(filePath, items) {
|
|
436
|
+
this.filePath = filePath;
|
|
437
|
+
for (const item of items) {
|
|
438
|
+
const ref = new Reference(item);
|
|
439
|
+
this.references.push(ref);
|
|
440
|
+
this.addToIndices(ref);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Load library from file
|
|
445
|
+
*/
|
|
446
|
+
static async load(filePath) {
|
|
447
|
+
const items = await parseCslJson(filePath);
|
|
448
|
+
const library = new Library(filePath, items);
|
|
449
|
+
library.currentHash = await computeFileHash(filePath);
|
|
450
|
+
return library;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Save library to file
|
|
454
|
+
*/
|
|
455
|
+
async save() {
|
|
456
|
+
const items = this.references.map((ref) => ref.getItem());
|
|
457
|
+
await writeCslJson(this.filePath, items);
|
|
458
|
+
this.currentHash = await computeFileHash(this.filePath);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Reloads the library from file if it was modified externally.
|
|
462
|
+
* Self-writes (detected via hash comparison) are skipped.
|
|
463
|
+
* @returns true if reload occurred, false if skipped (self-write detected)
|
|
464
|
+
*/
|
|
465
|
+
async reload() {
|
|
466
|
+
const newHash = await computeFileHash(this.filePath);
|
|
467
|
+
if (newHash === this.currentHash) {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
const items = await parseCslJson(this.filePath);
|
|
471
|
+
this.references = [];
|
|
472
|
+
this.uuidIndex.clear();
|
|
473
|
+
this.idIndex.clear();
|
|
474
|
+
this.doiIndex.clear();
|
|
475
|
+
this.pmidIndex.clear();
|
|
476
|
+
for (const item of items) {
|
|
477
|
+
const ref = new Reference(item);
|
|
478
|
+
this.references.push(ref);
|
|
479
|
+
this.addToIndices(ref);
|
|
480
|
+
}
|
|
481
|
+
this.currentHash = newHash;
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Add a reference to the library
|
|
486
|
+
* @param item - The CSL item to add
|
|
487
|
+
* @returns The added CSL item (with generated ID and UUID)
|
|
488
|
+
*/
|
|
489
|
+
async add(item) {
|
|
490
|
+
const existingIds = new Set(this.references.map((ref2) => ref2.getId()));
|
|
491
|
+
const ref = Reference.create(item, { existingIds });
|
|
492
|
+
this.references.push(ref);
|
|
493
|
+
this.addToIndices(ref);
|
|
494
|
+
return ref.getItem();
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Remove a reference by citation ID or UUID.
|
|
498
|
+
* @param identifier - The citation ID or UUID of the reference to remove
|
|
499
|
+
* @param options - Remove options (byUuid to use UUID lookup)
|
|
500
|
+
* @returns Remove result with removed status and the removed item
|
|
501
|
+
*/
|
|
502
|
+
async remove(identifier, options = {}) {
|
|
503
|
+
const { byUuid = false } = options;
|
|
504
|
+
const ref = byUuid ? this.uuidIndex.get(identifier) : this.idIndex.get(identifier);
|
|
505
|
+
if (!ref) {
|
|
506
|
+
return { removed: false };
|
|
507
|
+
}
|
|
508
|
+
const removedItem = ref.getItem();
|
|
509
|
+
const removed = this.removeReference(ref);
|
|
510
|
+
return { removed, removedItem };
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Update a reference by citation ID or UUID.
|
|
514
|
+
* @param identifier - The citation ID or UUID of the reference to update
|
|
515
|
+
* @param updates - Partial updates to apply to the reference
|
|
516
|
+
* @param options - Update options (byUuid to use UUID lookup, onIdCollision for collision handling)
|
|
517
|
+
* @returns Update result with updated item, success status, and any ID changes
|
|
518
|
+
*/
|
|
519
|
+
async update(identifier, updates, options = {}) {
|
|
520
|
+
const { byUuid = false, ...updateOptions } = options;
|
|
521
|
+
const ref = byUuid ? this.uuidIndex.get(identifier) : this.idIndex.get(identifier);
|
|
522
|
+
if (!ref) {
|
|
523
|
+
return { updated: false };
|
|
524
|
+
}
|
|
525
|
+
return this.updateReference(ref, updates, updateOptions);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Find a reference by citation ID or UUID.
|
|
529
|
+
* @param identifier - The citation ID or UUID of the reference to find
|
|
530
|
+
* @param options - Find options (byUuid to use UUID lookup)
|
|
531
|
+
* @returns The CSL item if found, undefined otherwise
|
|
532
|
+
*/
|
|
533
|
+
async find(identifier, options = {}) {
|
|
534
|
+
const { byUuid = false } = options;
|
|
535
|
+
const ref = byUuid ? this.uuidIndex.get(identifier) : this.idIndex.get(identifier);
|
|
536
|
+
return ref?.getItem();
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Find a reference by DOI
|
|
540
|
+
*/
|
|
541
|
+
findByDoi(doi) {
|
|
542
|
+
return this.doiIndex.get(doi);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Find a reference by PMID
|
|
546
|
+
*/
|
|
547
|
+
findByPmid(pmid) {
|
|
548
|
+
return this.pmidIndex.get(pmid);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Get all references
|
|
552
|
+
*/
|
|
553
|
+
async getAll() {
|
|
554
|
+
return this.references.map((ref) => ref.getItem());
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Get the file path
|
|
558
|
+
*/
|
|
559
|
+
getFilePath() {
|
|
560
|
+
return this.filePath;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Get the current file hash
|
|
564
|
+
* Returns null if the library has not been loaded or saved yet
|
|
565
|
+
*/
|
|
566
|
+
getCurrentHash() {
|
|
567
|
+
return this.currentHash;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Add reference to all indices
|
|
571
|
+
*/
|
|
572
|
+
addToIndices(ref) {
|
|
573
|
+
this.uuidIndex.set(ref.getUuid(), ref);
|
|
574
|
+
this.idIndex.set(ref.getId(), ref);
|
|
575
|
+
const doi = ref.getDoi();
|
|
576
|
+
if (doi) {
|
|
577
|
+
this.doiIndex.set(doi, ref);
|
|
578
|
+
}
|
|
579
|
+
const pmid = ref.getPmid();
|
|
580
|
+
if (pmid) {
|
|
581
|
+
this.pmidIndex.set(pmid, ref);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Remove reference from all indices and array
|
|
586
|
+
*/
|
|
587
|
+
removeReference(ref) {
|
|
588
|
+
const index = this.references.indexOf(ref);
|
|
589
|
+
if (index === -1) {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
this.references.splice(index, 1);
|
|
593
|
+
this.removeFromIndices(ref);
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Update a reference with partial updates.
|
|
598
|
+
* Preserves uuid and created_at, updates timestamp.
|
|
599
|
+
*/
|
|
600
|
+
updateReference(ref, updates, options = {}) {
|
|
601
|
+
const index = this.references.indexOf(ref);
|
|
602
|
+
if (index === -1) {
|
|
603
|
+
return { updated: false };
|
|
604
|
+
}
|
|
605
|
+
const existingItem = ref.getItem();
|
|
606
|
+
const currentId = ref.getId();
|
|
607
|
+
const { newId, idChanged, collision } = this.resolveNewId(
|
|
608
|
+
updates.id ?? existingItem.id,
|
|
609
|
+
currentId,
|
|
610
|
+
options
|
|
611
|
+
);
|
|
612
|
+
if (collision) {
|
|
613
|
+
return { updated: false, idCollision: true };
|
|
614
|
+
}
|
|
615
|
+
const updatedItem = this.buildUpdatedItem(existingItem, updates, newId);
|
|
616
|
+
this.removeFromIndices(ref);
|
|
617
|
+
const newRef = new Reference(updatedItem);
|
|
618
|
+
this.references[index] = newRef;
|
|
619
|
+
this.addToIndices(newRef);
|
|
620
|
+
const result = { updated: true, item: newRef.getItem() };
|
|
621
|
+
if (idChanged) {
|
|
622
|
+
result.idChanged = true;
|
|
623
|
+
result.newId = newId;
|
|
624
|
+
}
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Resolve the new ID, handling collisions based on options.
|
|
629
|
+
*/
|
|
630
|
+
resolveNewId(requestedId, currentId, options) {
|
|
631
|
+
if (requestedId === currentId) {
|
|
632
|
+
return { newId: requestedId, idChanged: false, collision: false };
|
|
633
|
+
}
|
|
634
|
+
const conflictingRef = this.idIndex.get(requestedId);
|
|
635
|
+
if (!conflictingRef) {
|
|
636
|
+
return { newId: requestedId, idChanged: false, collision: false };
|
|
637
|
+
}
|
|
638
|
+
const onIdCollision = options.onIdCollision ?? "fail";
|
|
639
|
+
if (onIdCollision === "fail") {
|
|
640
|
+
return { newId: requestedId, idChanged: false, collision: true };
|
|
641
|
+
}
|
|
642
|
+
const existingIds = new Set(this.references.map((r) => r.getId()));
|
|
643
|
+
existingIds.delete(currentId);
|
|
644
|
+
const resolvedId = this.resolveIdCollision(requestedId, existingIds);
|
|
645
|
+
return { newId: resolvedId, idChanged: true, collision: false };
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Build the updated CslItem, preserving uuid and created_at.
|
|
649
|
+
*/
|
|
650
|
+
buildUpdatedItem(existingItem, updates, newId) {
|
|
651
|
+
return {
|
|
652
|
+
...existingItem,
|
|
653
|
+
...updates,
|
|
654
|
+
id: newId,
|
|
655
|
+
type: updates.type ?? existingItem.type,
|
|
656
|
+
custom: {
|
|
657
|
+
...existingItem.custom || {},
|
|
658
|
+
...updates.custom || {},
|
|
659
|
+
uuid: existingItem.custom?.uuid || "",
|
|
660
|
+
created_at: existingItem.custom?.created_at || (/* @__PURE__ */ new Date()).toISOString(),
|
|
661
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Remove a reference from all indices.
|
|
667
|
+
*/
|
|
668
|
+
removeFromIndices(ref) {
|
|
669
|
+
this.uuidIndex.delete(ref.getUuid());
|
|
670
|
+
this.idIndex.delete(ref.getId());
|
|
671
|
+
const doi = ref.getDoi();
|
|
672
|
+
if (doi) {
|
|
673
|
+
this.doiIndex.delete(doi);
|
|
674
|
+
}
|
|
675
|
+
const pmid = ref.getPmid();
|
|
676
|
+
if (pmid) {
|
|
677
|
+
this.pmidIndex.delete(pmid);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Generate an alphabetic suffix for ID collision resolution.
|
|
682
|
+
* 0 -> 'a', 1 -> 'b', ..., 25 -> 'z', 26 -> 'aa', etc.
|
|
683
|
+
*/
|
|
684
|
+
generateSuffix(index) {
|
|
685
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
|
686
|
+
let suffix = "";
|
|
687
|
+
let n = index;
|
|
688
|
+
do {
|
|
689
|
+
suffix = alphabet[n % 26] + suffix;
|
|
690
|
+
n = Math.floor(n / 26) - 1;
|
|
691
|
+
} while (n >= 0);
|
|
692
|
+
return suffix;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Resolve ID collision by appending alphabetic suffix.
|
|
696
|
+
*/
|
|
697
|
+
resolveIdCollision(baseId, existingIds) {
|
|
698
|
+
if (!existingIds.has(baseId)) {
|
|
699
|
+
return baseId;
|
|
700
|
+
}
|
|
701
|
+
let index = 0;
|
|
702
|
+
let newId;
|
|
703
|
+
do {
|
|
704
|
+
const suffix = this.generateSuffix(index);
|
|
705
|
+
newId = `${baseId}${suffix}`;
|
|
706
|
+
index++;
|
|
707
|
+
} while (existingIds.has(newId));
|
|
708
|
+
return newId;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const VALID_FIELDS = /* @__PURE__ */ new Set([
|
|
712
|
+
"author",
|
|
713
|
+
"title",
|
|
714
|
+
"year",
|
|
715
|
+
"doi",
|
|
716
|
+
"pmid",
|
|
717
|
+
"pmcid",
|
|
718
|
+
"url",
|
|
719
|
+
"keyword",
|
|
720
|
+
"tag"
|
|
721
|
+
]);
|
|
722
|
+
function isWhitespace(query, index) {
|
|
723
|
+
return /\s/.test(query.charAt(index));
|
|
724
|
+
}
|
|
725
|
+
function isQuote(query, index) {
|
|
726
|
+
return query.charAt(index) === '"';
|
|
727
|
+
}
|
|
728
|
+
function tokenize(query) {
|
|
729
|
+
const tokens = [];
|
|
730
|
+
let i = 0;
|
|
731
|
+
while (i < query.length) {
|
|
732
|
+
if (isWhitespace(query, i)) {
|
|
733
|
+
i++;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const result = parseNextToken(query, i);
|
|
737
|
+
if (result.token) {
|
|
738
|
+
tokens.push(result.token);
|
|
739
|
+
}
|
|
740
|
+
i = result.nextIndex;
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
original: query,
|
|
744
|
+
tokens
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function hasWhitespaceBetween(query, start, end) {
|
|
748
|
+
for (let j = start; j < end; j++) {
|
|
749
|
+
if (isWhitespace(query, j)) {
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
function tryParseFieldValue(query, startIndex) {
|
|
756
|
+
const colonIndex = query.indexOf(":", startIndex);
|
|
757
|
+
if (colonIndex === -1) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
if (hasWhitespaceBetween(query, startIndex, colonIndex)) {
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
const fieldName = query.substring(startIndex, colonIndex);
|
|
764
|
+
if (!VALID_FIELDS.has(fieldName)) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
const afterColon = colonIndex + 1;
|
|
768
|
+
if (afterColon >= query.length || isWhitespace(query, afterColon)) {
|
|
769
|
+
return { token: null, nextIndex: afterColon };
|
|
770
|
+
}
|
|
771
|
+
if (isQuote(query, afterColon)) {
|
|
772
|
+
const quoteResult = parseQuotedValue(query, afterColon);
|
|
773
|
+
if (quoteResult.value !== null) {
|
|
774
|
+
return {
|
|
775
|
+
token: {
|
|
776
|
+
raw: query.substring(startIndex, quoteResult.nextIndex),
|
|
777
|
+
value: quoteResult.value,
|
|
778
|
+
field: fieldName,
|
|
779
|
+
isPhrase: true
|
|
780
|
+
},
|
|
781
|
+
nextIndex: quoteResult.nextIndex
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const valueResult = parseUnquotedValue(query, afterColon);
|
|
787
|
+
return {
|
|
788
|
+
token: {
|
|
789
|
+
raw: query.substring(startIndex, valueResult.nextIndex),
|
|
790
|
+
value: valueResult.value,
|
|
791
|
+
field: fieldName,
|
|
792
|
+
isPhrase: false
|
|
793
|
+
},
|
|
794
|
+
nextIndex: valueResult.nextIndex
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function parseQuotedToken(query, startIndex) {
|
|
798
|
+
const quoteResult = parseQuotedValue(query, startIndex);
|
|
799
|
+
if (quoteResult.value !== null) {
|
|
800
|
+
return {
|
|
801
|
+
token: {
|
|
802
|
+
raw: query.substring(startIndex, quoteResult.nextIndex),
|
|
803
|
+
value: quoteResult.value,
|
|
804
|
+
isPhrase: true
|
|
805
|
+
},
|
|
806
|
+
nextIndex: quoteResult.nextIndex
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (quoteResult.nextIndex > startIndex) {
|
|
810
|
+
return { token: null, nextIndex: quoteResult.nextIndex };
|
|
811
|
+
}
|
|
812
|
+
const valueResult = parseUnquotedValue(query, startIndex, true);
|
|
813
|
+
return {
|
|
814
|
+
token: {
|
|
815
|
+
raw: valueResult.value,
|
|
816
|
+
value: valueResult.value,
|
|
817
|
+
isPhrase: false
|
|
818
|
+
},
|
|
819
|
+
nextIndex: valueResult.nextIndex
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function parseRegularToken(query, startIndex) {
|
|
823
|
+
const valueResult = parseUnquotedValue(query, startIndex);
|
|
824
|
+
return {
|
|
825
|
+
token: {
|
|
826
|
+
raw: valueResult.value,
|
|
827
|
+
value: valueResult.value,
|
|
828
|
+
isPhrase: false
|
|
829
|
+
},
|
|
830
|
+
nextIndex: valueResult.nextIndex
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function parseNextToken(query, startIndex) {
|
|
834
|
+
const fieldResult = tryParseFieldValue(query, startIndex);
|
|
835
|
+
if (fieldResult !== null) {
|
|
836
|
+
return fieldResult;
|
|
837
|
+
}
|
|
838
|
+
if (isQuote(query, startIndex)) {
|
|
839
|
+
return parseQuotedToken(query, startIndex);
|
|
840
|
+
}
|
|
841
|
+
return parseRegularToken(query, startIndex);
|
|
842
|
+
}
|
|
843
|
+
function parseQuotedValue(query, startIndex) {
|
|
844
|
+
if (!isQuote(query, startIndex)) {
|
|
845
|
+
return { value: null, nextIndex: startIndex };
|
|
846
|
+
}
|
|
847
|
+
let i = startIndex + 1;
|
|
848
|
+
const valueStart = i;
|
|
849
|
+
while (i < query.length && !isQuote(query, i)) {
|
|
850
|
+
i++;
|
|
851
|
+
}
|
|
852
|
+
if (i >= query.length) {
|
|
853
|
+
return { value: null, nextIndex: startIndex };
|
|
854
|
+
}
|
|
855
|
+
const value = query.substring(valueStart, i);
|
|
856
|
+
i++;
|
|
857
|
+
if (value.trim() === "") {
|
|
858
|
+
return { value: null, nextIndex: i };
|
|
859
|
+
}
|
|
860
|
+
return { value, nextIndex: i };
|
|
861
|
+
}
|
|
862
|
+
function parseUnquotedValue(query, startIndex, includeQuotes = false) {
|
|
863
|
+
let i = startIndex;
|
|
864
|
+
while (i < query.length && !isWhitespace(query, i)) {
|
|
865
|
+
if (!includeQuotes && isQuote(query, i)) {
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
i++;
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
value: query.substring(startIndex, i),
|
|
872
|
+
nextIndex: i
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function normalize(text) {
|
|
876
|
+
let normalized = text.normalize("NFKC");
|
|
877
|
+
normalized = normalized.toLowerCase();
|
|
878
|
+
normalized = normalized.normalize("NFD").replace(new RegExp("\\p{M}", "gu"), "");
|
|
879
|
+
normalized = normalized.replace(/[^\p{L}\p{N}/\s]/gu, " ");
|
|
880
|
+
normalized = normalized.replace(/\s+/g, " ").trim();
|
|
881
|
+
return normalized;
|
|
882
|
+
}
|
|
883
|
+
function normalizePreservingCase(text) {
|
|
884
|
+
let normalized = text.normalize("NFKC");
|
|
885
|
+
normalized = normalized.normalize("NFD").replace(new RegExp("\\p{M}", "gu"), "");
|
|
886
|
+
normalized = normalized.replace(/\s+/g, " ").trim();
|
|
887
|
+
return normalized;
|
|
888
|
+
}
|
|
889
|
+
function hasConsecutiveUppercase(text) {
|
|
890
|
+
const pattern = /[A-Z]{2,}/;
|
|
891
|
+
return pattern.test(text);
|
|
892
|
+
}
|
|
893
|
+
function extractUppercaseSegments(text) {
|
|
894
|
+
const pattern = /[A-Z]{2,}/g;
|
|
895
|
+
const segments = [];
|
|
896
|
+
for (const match of text.matchAll(pattern)) {
|
|
897
|
+
segments.push({
|
|
898
|
+
segment: match[0],
|
|
899
|
+
start: match.index,
|
|
900
|
+
end: match.index + match[0].length
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return segments;
|
|
904
|
+
}
|
|
905
|
+
function escapeRegex(str) {
|
|
906
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
907
|
+
}
|
|
908
|
+
function normalizeWhitespace(text) {
|
|
909
|
+
return text.replace(/\s+/g, " ").trim();
|
|
910
|
+
}
|
|
911
|
+
function allUppercaseSegmentsExist(segments, target) {
|
|
912
|
+
return segments.every((seg) => target.includes(seg.segment));
|
|
913
|
+
}
|
|
914
|
+
function buildMatchPattern(query, segments) {
|
|
915
|
+
const patternParts = [];
|
|
916
|
+
let lastEnd = 0;
|
|
917
|
+
for (const seg of segments) {
|
|
918
|
+
if (seg.start > lastEnd) {
|
|
919
|
+
const beforePart = query.slice(lastEnd, seg.start);
|
|
920
|
+
if (beforePart.trim()) {
|
|
921
|
+
patternParts.push(escapeRegex(beforePart));
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
patternParts.push(`(?:${escapeRegex(seg.segment)})`);
|
|
925
|
+
lastEnd = seg.end;
|
|
926
|
+
}
|
|
927
|
+
if (lastEnd < query.length) {
|
|
928
|
+
const afterPart = query.slice(lastEnd);
|
|
929
|
+
if (afterPart.trim()) {
|
|
930
|
+
patternParts.push(escapeRegex(afterPart));
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return patternParts.join(".*?");
|
|
934
|
+
}
|
|
935
|
+
function matchWithUppercaseSensitivity(query, target) {
|
|
936
|
+
if (query === "") {
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
if (target === "") {
|
|
940
|
+
return false;
|
|
941
|
+
}
|
|
942
|
+
const normalizedQuery = normalizeWhitespace(query);
|
|
943
|
+
const normalizedTarget = normalizeWhitespace(target);
|
|
944
|
+
if (!hasConsecutiveUppercase(normalizedQuery)) {
|
|
945
|
+
return normalizedTarget.toLowerCase().includes(normalizedQuery.toLowerCase());
|
|
946
|
+
}
|
|
947
|
+
const segments = extractUppercaseSegments(normalizedQuery);
|
|
948
|
+
if (!allUppercaseSegmentsExist(segments, target)) {
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
const pattern = buildMatchPattern(normalizedQuery, segments);
|
|
952
|
+
try {
|
|
953
|
+
const regex = new RegExp(pattern, "i");
|
|
954
|
+
return regex.test(normalizedTarget);
|
|
955
|
+
} catch {
|
|
956
|
+
return normalizedTarget.toLowerCase().includes(normalizedQuery.toLowerCase());
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const ID_FIELDS = /* @__PURE__ */ new Set(["DOI", "PMID", "PMCID", "URL"]);
|
|
960
|
+
function extractYear$2(reference) {
|
|
961
|
+
if (reference.issued?.["date-parts"]?.[0]?.[0]) {
|
|
962
|
+
return String(reference.issued["date-parts"][0][0]);
|
|
963
|
+
}
|
|
964
|
+
return "0000";
|
|
965
|
+
}
|
|
966
|
+
function extractAuthors(reference) {
|
|
967
|
+
if (!reference.author || reference.author.length === 0) {
|
|
968
|
+
return "";
|
|
969
|
+
}
|
|
970
|
+
return reference.author.map((author) => {
|
|
971
|
+
const family = author.family || "";
|
|
972
|
+
const given = author.given || "";
|
|
973
|
+
return given ? `${family} ${given}` : family;
|
|
974
|
+
}).join(" ");
|
|
975
|
+
}
|
|
976
|
+
function getFieldValue(reference, field) {
|
|
977
|
+
if (field === "year") {
|
|
978
|
+
return extractYear$2(reference);
|
|
979
|
+
}
|
|
980
|
+
if (field === "author") {
|
|
981
|
+
return extractAuthors(reference);
|
|
982
|
+
}
|
|
983
|
+
const value = reference[field];
|
|
984
|
+
if (typeof value === "string") {
|
|
985
|
+
return value;
|
|
986
|
+
}
|
|
987
|
+
if (field.startsWith("custom.")) {
|
|
988
|
+
const customField = field.substring(7);
|
|
989
|
+
const customValue = reference.custom?.[customField];
|
|
990
|
+
if (typeof customValue === "string") {
|
|
991
|
+
return customValue;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
function matchUrl(queryValue, reference) {
|
|
997
|
+
if (reference.URL === queryValue) {
|
|
998
|
+
return {
|
|
999
|
+
field: "URL",
|
|
1000
|
+
strength: "exact",
|
|
1001
|
+
value: reference.URL
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const additionalUrls = reference.custom?.additional_urls;
|
|
1005
|
+
if (Array.isArray(additionalUrls)) {
|
|
1006
|
+
for (const url of additionalUrls) {
|
|
1007
|
+
if (typeof url === "string" && url === queryValue) {
|
|
1008
|
+
return {
|
|
1009
|
+
field: "custom.additional_urls",
|
|
1010
|
+
strength: "exact",
|
|
1011
|
+
value: url
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
function matchKeyword(queryValue, reference) {
|
|
1019
|
+
if (!reference.keyword || !Array.isArray(reference.keyword)) {
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
const normalizedQuery = normalizePreservingCase(queryValue);
|
|
1023
|
+
for (const keyword of reference.keyword) {
|
|
1024
|
+
if (typeof keyword === "string") {
|
|
1025
|
+
const normalizedKeyword = normalizePreservingCase(keyword);
|
|
1026
|
+
if (matchWithUppercaseSensitivity(normalizedQuery, normalizedKeyword)) {
|
|
1027
|
+
return {
|
|
1028
|
+
field: "keyword",
|
|
1029
|
+
strength: "partial",
|
|
1030
|
+
value: keyword
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
function matchTag(queryValue, reference) {
|
|
1038
|
+
if (!reference.custom?.tags || !Array.isArray(reference.custom.tags)) {
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
const normalizedQuery = normalizePreservingCase(queryValue);
|
|
1042
|
+
for (const tag of reference.custom.tags) {
|
|
1043
|
+
if (typeof tag === "string") {
|
|
1044
|
+
const normalizedTag = normalizePreservingCase(tag);
|
|
1045
|
+
if (matchWithUppercaseSensitivity(normalizedQuery, normalizedTag)) {
|
|
1046
|
+
return {
|
|
1047
|
+
field: "tag",
|
|
1048
|
+
strength: "partial",
|
|
1049
|
+
value: tag
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const FIELD_MAP = {
|
|
1057
|
+
author: "author",
|
|
1058
|
+
title: "title",
|
|
1059
|
+
doi: "DOI",
|
|
1060
|
+
pmid: "PMID",
|
|
1061
|
+
pmcid: "PMCID"
|
|
1062
|
+
};
|
|
1063
|
+
function matchYearField(tokenValue, reference) {
|
|
1064
|
+
const year = extractYear$2(reference);
|
|
1065
|
+
if (year === tokenValue) {
|
|
1066
|
+
return {
|
|
1067
|
+
field: "year",
|
|
1068
|
+
strength: "exact",
|
|
1069
|
+
value: year
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
return null;
|
|
1073
|
+
}
|
|
1074
|
+
function matchFieldValue(field, tokenValue, reference) {
|
|
1075
|
+
const fieldValue = getFieldValue(reference, field);
|
|
1076
|
+
if (fieldValue === null) {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
if (ID_FIELDS.has(field)) {
|
|
1080
|
+
if (fieldValue === tokenValue) {
|
|
1081
|
+
return {
|
|
1082
|
+
field,
|
|
1083
|
+
strength: "exact",
|
|
1084
|
+
value: fieldValue
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
const normalizedFieldValue = normalizePreservingCase(fieldValue);
|
|
1090
|
+
const normalizedQuery = normalizePreservingCase(tokenValue);
|
|
1091
|
+
if (matchWithUppercaseSensitivity(normalizedQuery, normalizedFieldValue)) {
|
|
1092
|
+
return {
|
|
1093
|
+
field,
|
|
1094
|
+
strength: "partial",
|
|
1095
|
+
value: fieldValue
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
function matchSpecificField(token, reference) {
|
|
1101
|
+
const matches = [];
|
|
1102
|
+
const fieldToSearch = token.field;
|
|
1103
|
+
if (fieldToSearch === "url") {
|
|
1104
|
+
const urlMatch = matchUrl(token.value, reference);
|
|
1105
|
+
if (urlMatch) matches.push(urlMatch);
|
|
1106
|
+
return matches;
|
|
1107
|
+
}
|
|
1108
|
+
if (fieldToSearch === "year") {
|
|
1109
|
+
const yearMatch = matchYearField(token.value, reference);
|
|
1110
|
+
if (yearMatch) matches.push(yearMatch);
|
|
1111
|
+
return matches;
|
|
1112
|
+
}
|
|
1113
|
+
if (fieldToSearch === "keyword") {
|
|
1114
|
+
const keywordMatch = matchKeyword(token.value, reference);
|
|
1115
|
+
if (keywordMatch) matches.push(keywordMatch);
|
|
1116
|
+
return matches;
|
|
1117
|
+
}
|
|
1118
|
+
if (fieldToSearch === "tag") {
|
|
1119
|
+
const tagMatch = matchTag(token.value, reference);
|
|
1120
|
+
if (tagMatch) matches.push(tagMatch);
|
|
1121
|
+
return matches;
|
|
1122
|
+
}
|
|
1123
|
+
const actualField = FIELD_MAP[fieldToSearch] || fieldToSearch;
|
|
1124
|
+
const match = matchFieldValue(actualField, token.value, reference);
|
|
1125
|
+
if (match) matches.push(match);
|
|
1126
|
+
return matches;
|
|
1127
|
+
}
|
|
1128
|
+
const STANDARD_SEARCH_FIELDS = [
|
|
1129
|
+
"title",
|
|
1130
|
+
"author",
|
|
1131
|
+
"container-title",
|
|
1132
|
+
"publisher",
|
|
1133
|
+
"DOI",
|
|
1134
|
+
"PMID",
|
|
1135
|
+
"PMCID",
|
|
1136
|
+
"abstract"
|
|
1137
|
+
];
|
|
1138
|
+
function matchSingleField(field, tokenValue, reference) {
|
|
1139
|
+
if (field === "year") {
|
|
1140
|
+
return matchYearField(tokenValue, reference);
|
|
1141
|
+
}
|
|
1142
|
+
if (field === "URL") {
|
|
1143
|
+
return matchUrl(tokenValue, reference);
|
|
1144
|
+
}
|
|
1145
|
+
if (field === "keyword") {
|
|
1146
|
+
return matchKeyword(tokenValue, reference);
|
|
1147
|
+
}
|
|
1148
|
+
if (field === "tag") {
|
|
1149
|
+
return matchTag(tokenValue, reference);
|
|
1150
|
+
}
|
|
1151
|
+
return matchFieldValue(field, tokenValue, reference);
|
|
1152
|
+
}
|
|
1153
|
+
function matchAllFields(token, reference) {
|
|
1154
|
+
const matches = [];
|
|
1155
|
+
const specialFields = ["year", "URL", "keyword", "tag"];
|
|
1156
|
+
for (const field of specialFields) {
|
|
1157
|
+
const match = matchSingleField(field, token.value, reference);
|
|
1158
|
+
if (match) matches.push(match);
|
|
1159
|
+
}
|
|
1160
|
+
for (const field of STANDARD_SEARCH_FIELDS) {
|
|
1161
|
+
const match = matchFieldValue(field, token.value, reference);
|
|
1162
|
+
if (match) matches.push(match);
|
|
1163
|
+
}
|
|
1164
|
+
return matches;
|
|
1165
|
+
}
|
|
1166
|
+
function matchToken(token, reference) {
|
|
1167
|
+
if (token.field) {
|
|
1168
|
+
return matchSpecificField(token, reference);
|
|
1169
|
+
}
|
|
1170
|
+
return matchAllFields(token, reference);
|
|
1171
|
+
}
|
|
1172
|
+
function matchReference(reference, tokens) {
|
|
1173
|
+
if (tokens.length === 0) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
const tokenMatches = [];
|
|
1177
|
+
let overallStrength = "none";
|
|
1178
|
+
for (const token of tokens) {
|
|
1179
|
+
const matches = matchToken(token, reference);
|
|
1180
|
+
if (matches.length === 0) {
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
const tokenStrength = matches.some((m) => m.strength === "exact") ? "exact" : "partial";
|
|
1184
|
+
if (tokenStrength === "exact") {
|
|
1185
|
+
overallStrength = "exact";
|
|
1186
|
+
} else if (tokenStrength === "partial" && overallStrength === "none") {
|
|
1187
|
+
overallStrength = "partial";
|
|
1188
|
+
}
|
|
1189
|
+
tokenMatches.push({
|
|
1190
|
+
token,
|
|
1191
|
+
matches
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
const score = overallStrength === "exact" ? 100 + tokenMatches.length : 50 + tokenMatches.length;
|
|
1195
|
+
return {
|
|
1196
|
+
reference,
|
|
1197
|
+
tokenMatches,
|
|
1198
|
+
overallStrength,
|
|
1199
|
+
score
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
function search(references, tokens) {
|
|
1203
|
+
const results = [];
|
|
1204
|
+
for (const reference of references) {
|
|
1205
|
+
const match = matchReference(reference, tokens);
|
|
1206
|
+
if (match) {
|
|
1207
|
+
results.push(match);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return results;
|
|
1211
|
+
}
|
|
1212
|
+
function extractYear$1(reference) {
|
|
1213
|
+
if (reference.issued?.["date-parts"]?.[0]?.[0]) {
|
|
1214
|
+
return String(reference.issued["date-parts"][0][0]);
|
|
1215
|
+
}
|
|
1216
|
+
return "0000";
|
|
1217
|
+
}
|
|
1218
|
+
function extractFirstAuthorFamily(reference) {
|
|
1219
|
+
if (!reference.author || reference.author.length === 0) {
|
|
1220
|
+
return "";
|
|
1221
|
+
}
|
|
1222
|
+
return reference.author[0]?.family || "";
|
|
1223
|
+
}
|
|
1224
|
+
function extractTitle(reference) {
|
|
1225
|
+
return reference.title || "";
|
|
1226
|
+
}
|
|
1227
|
+
function compareStrength(a, b) {
|
|
1228
|
+
const strengthOrder = { exact: 2, partial: 1, none: 0 };
|
|
1229
|
+
return strengthOrder[b] - strengthOrder[a];
|
|
1230
|
+
}
|
|
1231
|
+
function compareYear(a, b) {
|
|
1232
|
+
const yearA = extractYear$1(a);
|
|
1233
|
+
const yearB = extractYear$1(b);
|
|
1234
|
+
return Number(yearB) - Number(yearA);
|
|
1235
|
+
}
|
|
1236
|
+
function compareAuthor(a, b) {
|
|
1237
|
+
const authorA = extractFirstAuthorFamily(a).toLowerCase();
|
|
1238
|
+
const authorB = extractFirstAuthorFamily(b).toLowerCase();
|
|
1239
|
+
if (authorA === "" && authorB !== "") return 1;
|
|
1240
|
+
if (authorA !== "" && authorB === "") return -1;
|
|
1241
|
+
return authorA.localeCompare(authorB);
|
|
1242
|
+
}
|
|
1243
|
+
function compareTitle(a, b) {
|
|
1244
|
+
const titleA = extractTitle(a).toLowerCase();
|
|
1245
|
+
const titleB = extractTitle(b).toLowerCase();
|
|
1246
|
+
if (titleA === "" && titleB !== "") return 1;
|
|
1247
|
+
if (titleA !== "" && titleB === "") return -1;
|
|
1248
|
+
return titleA.localeCompare(titleB);
|
|
1249
|
+
}
|
|
1250
|
+
function sortResults(results) {
|
|
1251
|
+
const indexed = results.map((result, index) => ({ result, index }));
|
|
1252
|
+
const sorted = indexed.sort((a, b) => {
|
|
1253
|
+
const strengthDiff = compareStrength(a.result.overallStrength, b.result.overallStrength);
|
|
1254
|
+
if (strengthDiff !== 0) return strengthDiff;
|
|
1255
|
+
const yearDiff = compareYear(a.result.reference, b.result.reference);
|
|
1256
|
+
if (yearDiff !== 0) return yearDiff;
|
|
1257
|
+
const authorDiff = compareAuthor(a.result.reference, b.result.reference);
|
|
1258
|
+
if (authorDiff !== 0) return authorDiff;
|
|
1259
|
+
const titleDiff = compareTitle(a.result.reference, b.result.reference);
|
|
1260
|
+
if (titleDiff !== 0) return titleDiff;
|
|
1261
|
+
return a.index - b.index;
|
|
1262
|
+
});
|
|
1263
|
+
return sorted.map((item) => item.result);
|
|
1264
|
+
}
|
|
1265
|
+
function normalizeDoi(doi) {
|
|
1266
|
+
const normalized = doi.replace(/^https?:\/\/doi\.org\//i, "").replace(/^https?:\/\/dx\.doi\.org\//i, "").replace(/^doi:/i, "");
|
|
1267
|
+
return normalized;
|
|
1268
|
+
}
|
|
1269
|
+
function extractYear(item) {
|
|
1270
|
+
const dateParts = item.issued?.["date-parts"]?.[0];
|
|
1271
|
+
if (!dateParts || dateParts.length === 0) {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
return String(dateParts[0]);
|
|
1275
|
+
}
|
|
1276
|
+
function normalizeAuthors(item) {
|
|
1277
|
+
if (!item.author || item.author.length === 0) {
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
1280
|
+
const authorStrings = item.author.map((author) => {
|
|
1281
|
+
const family = author.family || "";
|
|
1282
|
+
const givenInitial = author.given ? author.given.charAt(0) : "";
|
|
1283
|
+
return `${family} ${givenInitial}`.trim();
|
|
1284
|
+
});
|
|
1285
|
+
return normalize(authorStrings.join(" "));
|
|
1286
|
+
}
|
|
1287
|
+
function checkDoiMatch(item, existing) {
|
|
1288
|
+
if (!item.DOI || !existing.DOI) {
|
|
1289
|
+
return null;
|
|
1290
|
+
}
|
|
1291
|
+
const normalizedItemDoi = normalizeDoi(item.DOI);
|
|
1292
|
+
const normalizedExistingDoi = normalizeDoi(existing.DOI);
|
|
1293
|
+
if (normalizedItemDoi === normalizedExistingDoi) {
|
|
1294
|
+
return {
|
|
1295
|
+
type: "doi",
|
|
1296
|
+
existing,
|
|
1297
|
+
details: {
|
|
1298
|
+
doi: normalizedExistingDoi
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
function checkPmidMatch(item, existing) {
|
|
1305
|
+
if (!item.PMID || !existing.PMID) {
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
if (item.PMID === existing.PMID) {
|
|
1309
|
+
return {
|
|
1310
|
+
type: "pmid",
|
|
1311
|
+
existing,
|
|
1312
|
+
details: {
|
|
1313
|
+
pmid: existing.PMID
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
function checkTitleAuthorYearMatch(item, existing) {
|
|
1320
|
+
const itemTitle = item.title ? normalize(item.title) : null;
|
|
1321
|
+
const existingTitle = existing.title ? normalize(existing.title) : null;
|
|
1322
|
+
const itemAuthors = normalizeAuthors(item);
|
|
1323
|
+
const existingAuthors = normalizeAuthors(existing);
|
|
1324
|
+
const itemYear = extractYear(item);
|
|
1325
|
+
const existingYear = extractYear(existing);
|
|
1326
|
+
if (!itemTitle || !existingTitle || !itemAuthors || !existingAuthors || !itemYear || !existingYear) {
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
if (itemTitle === existingTitle && itemAuthors === existingAuthors && itemYear === existingYear) {
|
|
1330
|
+
return {
|
|
1331
|
+
type: "title-author-year",
|
|
1332
|
+
existing,
|
|
1333
|
+
details: {
|
|
1334
|
+
normalizedTitle: existingTitle,
|
|
1335
|
+
normalizedAuthors: existingAuthors,
|
|
1336
|
+
year: existingYear
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
return null;
|
|
1341
|
+
}
|
|
1342
|
+
function checkSingleDuplicate(item, existing) {
|
|
1343
|
+
const doiMatch = checkDoiMatch(item, existing);
|
|
1344
|
+
if (doiMatch) {
|
|
1345
|
+
return doiMatch;
|
|
1346
|
+
}
|
|
1347
|
+
const pmidMatch = checkPmidMatch(item, existing);
|
|
1348
|
+
if (pmidMatch) {
|
|
1349
|
+
return pmidMatch;
|
|
1350
|
+
}
|
|
1351
|
+
return checkTitleAuthorYearMatch(item, existing);
|
|
1352
|
+
}
|
|
1353
|
+
function detectDuplicate(item, existingReferences) {
|
|
1354
|
+
const matches = [];
|
|
1355
|
+
const itemUuid = item.custom?.uuid;
|
|
1356
|
+
for (const existing of existingReferences) {
|
|
1357
|
+
if (itemUuid && existing.custom?.uuid === itemUuid) {
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const match = checkSingleDuplicate(item, existing);
|
|
1361
|
+
if (match) {
|
|
1362
|
+
matches.push(match);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
isDuplicate: matches.length > 0,
|
|
1367
|
+
matches
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
const DEFAULT_DEBOUNCE_MS = 500;
|
|
1371
|
+
const DEFAULT_POLL_INTERVAL_MS = 5e3;
|
|
1372
|
+
const DEFAULT_RETRY_DELAY_MS = 200;
|
|
1373
|
+
const DEFAULT_MAX_RETRIES = 10;
|
|
1374
|
+
function shouldIgnore(filePath) {
|
|
1375
|
+
const basename = path.basename(filePath);
|
|
1376
|
+
if (basename.endsWith(".tmp")) return true;
|
|
1377
|
+
if (basename.endsWith(".bak")) return true;
|
|
1378
|
+
if (basename.includes(".conflict.")) return true;
|
|
1379
|
+
if (basename.endsWith(".lock")) return true;
|
|
1380
|
+
if (basename.startsWith(".") && basename.endsWith(".swp")) return true;
|
|
1381
|
+
if (basename.endsWith("~")) return true;
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
class FileWatcher extends EventEmitter {
|
|
1385
|
+
watchPath;
|
|
1386
|
+
debounceMs;
|
|
1387
|
+
pollIntervalMs;
|
|
1388
|
+
usePolling;
|
|
1389
|
+
retryDelayMs;
|
|
1390
|
+
maxRetries;
|
|
1391
|
+
watcher = null;
|
|
1392
|
+
watching = false;
|
|
1393
|
+
debounceTimers = /* @__PURE__ */ new Map();
|
|
1394
|
+
constructor(watchPath, options) {
|
|
1395
|
+
super();
|
|
1396
|
+
this.watchPath = watchPath;
|
|
1397
|
+
this.debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
1398
|
+
this.pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
1399
|
+
this.usePolling = options?.usePolling ?? false;
|
|
1400
|
+
this.retryDelayMs = options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
1401
|
+
this.maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Start watching for file changes
|
|
1405
|
+
*/
|
|
1406
|
+
async start() {
|
|
1407
|
+
if (this.watching) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
return new Promise((resolve, reject) => {
|
|
1411
|
+
this.watcher = chokidar.watch(this.watchPath, {
|
|
1412
|
+
ignored: shouldIgnore,
|
|
1413
|
+
persistent: true,
|
|
1414
|
+
usePolling: this.usePolling,
|
|
1415
|
+
interval: this.pollIntervalMs,
|
|
1416
|
+
ignoreInitial: true,
|
|
1417
|
+
awaitWriteFinish: false
|
|
1418
|
+
});
|
|
1419
|
+
this.watcher.on("ready", () => {
|
|
1420
|
+
this.watching = true;
|
|
1421
|
+
this.emit("ready");
|
|
1422
|
+
resolve();
|
|
1423
|
+
});
|
|
1424
|
+
this.watcher.on("error", (error) => {
|
|
1425
|
+
this.emit("error", error);
|
|
1426
|
+
if (!this.watching) {
|
|
1427
|
+
reject(error);
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
this.watcher.on("change", (filePath) => {
|
|
1431
|
+
this.handleFileChange(filePath);
|
|
1432
|
+
});
|
|
1433
|
+
this.watcher.on("add", (filePath) => {
|
|
1434
|
+
this.handleFileChange(filePath);
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Handle file change with debouncing
|
|
1440
|
+
*/
|
|
1441
|
+
handleFileChange(filePath) {
|
|
1442
|
+
const existingTimer = this.debounceTimers.get(filePath);
|
|
1443
|
+
if (existingTimer) {
|
|
1444
|
+
clearTimeout(existingTimer);
|
|
1445
|
+
}
|
|
1446
|
+
const timer = setTimeout(() => {
|
|
1447
|
+
this.debounceTimers.delete(filePath);
|
|
1448
|
+
this.emit("change", filePath);
|
|
1449
|
+
this.tryParseJsonFile(filePath);
|
|
1450
|
+
}, this.debounceMs);
|
|
1451
|
+
this.debounceTimers.set(filePath, timer);
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Try to parse JSON file with retries
|
|
1455
|
+
*/
|
|
1456
|
+
async tryParseJsonFile(filePath) {
|
|
1457
|
+
if (path.extname(filePath).toLowerCase() !== ".json") {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
let lastError = null;
|
|
1461
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
1462
|
+
try {
|
|
1463
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
1464
|
+
const parsed = JSON.parse(content);
|
|
1465
|
+
this.emit("parsed", filePath, parsed);
|
|
1466
|
+
return;
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
lastError = error;
|
|
1469
|
+
if (attempt < this.maxRetries) {
|
|
1470
|
+
await this.delay(this.retryDelayMs);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
this.emit("parseError", filePath, lastError);
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Delay helper
|
|
1478
|
+
*/
|
|
1479
|
+
delay(ms) {
|
|
1480
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Stop watching for file changes
|
|
1484
|
+
*/
|
|
1485
|
+
close() {
|
|
1486
|
+
if (this.watcher) {
|
|
1487
|
+
this.watcher.close();
|
|
1488
|
+
this.watcher = null;
|
|
1489
|
+
}
|
|
1490
|
+
for (const timer of this.debounceTimers.values()) {
|
|
1491
|
+
clearTimeout(timer);
|
|
1492
|
+
}
|
|
1493
|
+
this.debounceTimers.clear();
|
|
1494
|
+
this.watching = false;
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Get the watched path
|
|
1498
|
+
*/
|
|
1499
|
+
getPath() {
|
|
1500
|
+
return this.watchPath;
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Check if the watcher is currently active
|
|
1504
|
+
*/
|
|
1505
|
+
isWatching() {
|
|
1506
|
+
return this.watching;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Get the debounce time in milliseconds
|
|
1510
|
+
*/
|
|
1511
|
+
getDebounceMs() {
|
|
1512
|
+
return this.debounceMs;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Get the poll interval in milliseconds
|
|
1516
|
+
*/
|
|
1517
|
+
getPollIntervalMs() {
|
|
1518
|
+
return this.pollIntervalMs;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Get the retry delay in milliseconds
|
|
1522
|
+
*/
|
|
1523
|
+
getRetryDelayMs() {
|
|
1524
|
+
return this.retryDelayMs;
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Get the maximum number of retries
|
|
1528
|
+
*/
|
|
1529
|
+
getMaxRetries() {
|
|
1530
|
+
return this.maxRetries;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
export {
|
|
1534
|
+
CslLibrarySchema as C,
|
|
1535
|
+
FileWatcher as F,
|
|
1536
|
+
Library as L,
|
|
1537
|
+
Reference as R,
|
|
1538
|
+
computeHash as a,
|
|
1539
|
+
sortResults as b,
|
|
1540
|
+
computeFileHash as c,
|
|
1541
|
+
detectDuplicate as d,
|
|
1542
|
+
CslItemSchema as e,
|
|
1543
|
+
serializeCslJson as f,
|
|
1544
|
+
generateId as g,
|
|
1545
|
+
generateIdWithCollisionCheck as h,
|
|
1546
|
+
normalizeText as i,
|
|
1547
|
+
generateUuid as j,
|
|
1548
|
+
isValidUuid as k,
|
|
1549
|
+
ensureCustomMetadata as l,
|
|
1550
|
+
extractUuidFromCustom as m,
|
|
1551
|
+
normalize as n,
|
|
1552
|
+
parseCslJson as p,
|
|
1553
|
+
search as s,
|
|
1554
|
+
tokenize as t,
|
|
1555
|
+
writeCslJson as w
|
|
1556
|
+
};
|
|
1557
|
+
//# sourceMappingURL=file-watcher-B-SiUw5f.js.map
|