@a9i5k4/dsh-literature 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,2347 @@
1
+ /* @a9i5k4/dsh-literature — DSH host half. Generated by build.mjs, do not edit. */
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name2 in all)
14
+ __defProp(target, name2, { get: all[name2], enumerable: true });
15
+ };
16
+
17
+ // src/node/http.js
18
+ var http_exports = {};
19
+ __export(http_exports, {
20
+ isLoopbackRequest: () => isLoopbackRequest,
21
+ parseRange: () => parseRange,
22
+ readJsonBody: () => readJsonBody,
23
+ readRawBody: () => readRawBody,
24
+ responseClosed: () => responseClosed,
25
+ writeJson: () => writeJson,
26
+ writeSseEvent: () => writeSseEvent,
27
+ writeSseHead: () => writeSseHead,
28
+ writeText: () => writeText
29
+ });
30
+ import { once } from "node:events";
31
+ function isLoopbackRequest(req) {
32
+ const remote = req.socket?.remoteAddress ?? "";
33
+ return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
34
+ }
35
+ function writeJson(res, status, body) {
36
+ const payload = Buffer.from(JSON.stringify(body), "utf8");
37
+ res.writeHead(status, {
38
+ "content-type": "application/json; charset=utf-8",
39
+ "content-length": payload.length,
40
+ "cache-control": "no-store"
41
+ });
42
+ res.end(payload);
43
+ }
44
+ function writeText(res, status, text) {
45
+ const payload = Buffer.from(text, "utf8");
46
+ res.writeHead(status, {
47
+ "content-type": "text/plain; charset=utf-8",
48
+ "content-length": payload.length,
49
+ "cache-control": "no-store"
50
+ });
51
+ res.end(payload);
52
+ }
53
+ async function readBody(req, limit) {
54
+ const chunks = [];
55
+ let size = 0;
56
+ for await (const chunk of req) {
57
+ size += chunk.length;
58
+ if (size > limit) {
59
+ req.destroy();
60
+ throw new Error(`request body too large (limit ${limit} bytes)`);
61
+ }
62
+ chunks.push(chunk);
63
+ }
64
+ return Buffer.concat(chunks);
65
+ }
66
+ async function readRawBody(req, limit = DEFAULT_LIMIT) {
67
+ if (req.method === "GET" || req.method === "HEAD") return Buffer.alloc(0);
68
+ return readBody(req, limit);
69
+ }
70
+ async function readJsonBody(req, limit = 8 * 1024 * 1024) {
71
+ const raw = await readRawBody(req, limit);
72
+ if (raw.length === 0) return {};
73
+ try {
74
+ return JSON.parse(raw.toString("utf8"));
75
+ } catch {
76
+ throw new Error("request body is not valid JSON");
77
+ }
78
+ }
79
+ function responseClosed(res) {
80
+ return once(res, "close");
81
+ }
82
+ function writeSseHead(res) {
83
+ res.writeHead(200, {
84
+ "content-type": "text/event-stream; charset=utf-8",
85
+ "cache-control": "no-cache, no-transform",
86
+ connection: "keep-alive",
87
+ "x-accel-buffering": "no"
88
+ });
89
+ res.write("retry: 3000\n\n");
90
+ }
91
+ function writeSseEvent(res, id, event, data) {
92
+ if (id !== void 0) res.write(`id: ${id}
93
+ `);
94
+ res.write(`event: ${event}
95
+ `);
96
+ res.write(`data: ${JSON.stringify(data)}
97
+
98
+ `);
99
+ }
100
+ function parseRange(header, total) {
101
+ if (!header) return null;
102
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
103
+ if (!m) return null;
104
+ const [, startRaw, endRaw] = m;
105
+ if (startRaw === "" && endRaw === "") return null;
106
+ let start;
107
+ let end;
108
+ if (startRaw === "") {
109
+ const suffix = Number(endRaw);
110
+ start = Math.max(0, total - suffix);
111
+ end = total - 1;
112
+ } else {
113
+ start = Number(startRaw);
114
+ end = endRaw === "" ? total - 1 : Math.min(Number(endRaw), total - 1);
115
+ }
116
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
117
+ return { invalid: true, total };
118
+ }
119
+ return { start, end, total };
120
+ }
121
+ var DEFAULT_LIMIT;
122
+ var init_http = __esm({
123
+ "src/node/http.js"() {
124
+ DEFAULT_LIMIT = 64 * 1024 * 1024;
125
+ }
126
+ });
127
+
128
+ // src/node/config.js
129
+ import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
130
+ import { dirname, join, resolve } from "node:path";
131
+ import { homedir } from "node:os";
132
+
133
+ // src/node/log.js
134
+ var PREFIX = "[dsh-literature]";
135
+ function log(...args) {
136
+ console.log(PREFIX, ...args);
137
+ }
138
+ function warn(...args) {
139
+ console.warn(PREFIX, ...args);
140
+ }
141
+ function error(...args) {
142
+ console.error(PREFIX, ...args);
143
+ }
144
+
145
+ // src/node/config.js
146
+ function dshHome() {
147
+ const fromEnv = process.env.DSH_HOME || process.env.DSH_CONFIG_DIR;
148
+ if (fromEnv) return resolve(fromEnv);
149
+ return resolve(homedir(), ".dsh");
150
+ }
151
+ var CONFIG_PATH = join(dshHome(), "dsh-literature.json");
152
+ var STORE_DIR = join(dshHome(), "storages", "dsh-literature");
153
+ var PDF_DIR = join(STORE_DIR, "pdfs");
154
+ var STORE_PATH = join(STORE_DIR, "store.json");
155
+ var DEFAULTS = {
156
+ version: 1,
157
+ /** 'zotero' writes through the Connector API; 'dir' exports to `dirPath`. */
158
+ saveMode: "zotero",
159
+ dirPath: "",
160
+ /** Collection names the user wants new items to land in. Advisory only — the
161
+ * Connector API saves into whatever the Zotero pane currently has selected. */
162
+ preferredCollections: [],
163
+ preferredTags: [],
164
+ /** Filename template for the 'dir' channel. */
165
+ naming: "{author}_{year}_{title}",
166
+ /** Fallback: regex-scan model replies for identifiers. Off by default. */
167
+ autoScanSession: false,
168
+ /** Unpaywall requires a contact email in the query string. */
169
+ unpaywallEmail: "",
170
+ retry: { maxAttempts: 3, baseDelayMs: 800, maxDelayMs: 8e3 },
171
+ fetchTimeoutMs: 3e4,
172
+ zoteroPort: 23119,
173
+ /** Overrides automatic data-dir detection from the Zotero profile prefs. */
174
+ dataDirOverride: ""
175
+ };
176
+ function merge(base, patch) {
177
+ const out = { ...base };
178
+ for (const [k, v] of Object.entries(patch ?? {})) {
179
+ if (v && typeof v === "object" && !Array.isArray(v) && typeof base[k] === "object" && base[k] !== null && !Array.isArray(base[k])) {
180
+ out[k] = merge(base[k], v);
181
+ } else if (v !== void 0) {
182
+ out[k] = v;
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ var cached = null;
188
+ async function loadConfig() {
189
+ if (cached) return cached;
190
+ let raw = null;
191
+ try {
192
+ raw = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
193
+ } catch {
194
+ raw = null;
195
+ }
196
+ cached = merge(DEFAULTS, raw);
197
+ return cached;
198
+ }
199
+ async function saveConfig(patch) {
200
+ const next = merge(await loadConfig(), patch);
201
+ cached = next;
202
+ await mkdir(dirname(CONFIG_PATH), { recursive: true });
203
+ const tmp = `${CONFIG_PATH}.tmp`;
204
+ await writeFile(tmp, JSON.stringify(next, null, 2), "utf8");
205
+ await rename(tmp, CONFIG_PATH);
206
+ return next;
207
+ }
208
+ async function writeJsonAtomic(path, value) {
209
+ await mkdir(dirname(path), { recursive: true });
210
+ const tmp = `${path}.tmp`;
211
+ await writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
212
+ await rename(tmp, path);
213
+ }
214
+ async function readJsonOrNull(path) {
215
+ try {
216
+ return JSON.parse(await readFile(path, "utf8"));
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+ async function ensureDirs() {
222
+ await mkdir(PDF_DIR, { recursive: true });
223
+ log("storage ready at", STORE_DIR);
224
+ }
225
+
226
+ // src/node/store/db.js
227
+ var EMPTY = { version: 1, items: {}, tasks: {}, annotations: {} };
228
+ var state = null;
229
+ var flushTimer = null;
230
+ var flushing = null;
231
+ async function load() {
232
+ if (state) return state;
233
+ const raw = await readJsonOrNull(STORE_PATH);
234
+ state = raw && raw.version === 1 ? { ...EMPTY, ...raw } : { ...EMPTY };
235
+ return state;
236
+ }
237
+ function scheduleFlush() {
238
+ if (flushTimer) return;
239
+ flushTimer = setTimeout(() => {
240
+ flushTimer = null;
241
+ flushing = persist().catch((e) => warn("persist failed:", e.message));
242
+ }, 250);
243
+ flushTimer.unref?.();
244
+ }
245
+ async function persist() {
246
+ const snapshot = state ?? await load();
247
+ await writeJsonAtomic(STORE_PATH, snapshot);
248
+ }
249
+ async function flush() {
250
+ if (flushTimer) {
251
+ clearTimeout(flushTimer);
252
+ flushTimer = null;
253
+ }
254
+ await persist();
255
+ if (flushing) {
256
+ await flushing;
257
+ flushing = null;
258
+ }
259
+ }
260
+ async function getItem(key) {
261
+ return (await load()).items[key] ?? null;
262
+ }
263
+ async function listItems() {
264
+ const s = await load();
265
+ return Object.values(s.items).sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
266
+ }
267
+ async function putItem(item) {
268
+ const s = await load();
269
+ const prev = s.items[item.key];
270
+ const next = { ...prev, ...item, updatedAt: Date.now() };
271
+ next.createdAt = prev?.createdAt ?? item.createdAt ?? Date.now();
272
+ s.items[item.key] = next;
273
+ scheduleFlush();
274
+ return next;
275
+ }
276
+ async function patchItem(key, patch) {
277
+ const s = await load();
278
+ const prev = s.items[key];
279
+ if (!prev) return null;
280
+ const next = { ...prev, ...patch, updatedAt: Date.now() };
281
+ s.items[key] = next;
282
+ scheduleFlush();
283
+ return next;
284
+ }
285
+ async function removeItem(key) {
286
+ const s = await load();
287
+ delete s.items[key];
288
+ delete s.annotations[key];
289
+ scheduleFlush();
290
+ }
291
+ async function putTask(task) {
292
+ const s = await load();
293
+ s.tasks[task.id] = { ...s.tasks[task.id], ...task, updatedAt: Date.now() };
294
+ scheduleFlush();
295
+ return s.tasks[task.id];
296
+ }
297
+ async function listTasks() {
298
+ return Object.values((await load()).tasks).sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
299
+ }
300
+ async function getAnnotations(key) {
301
+ return (await load()).annotations[key] ?? [];
302
+ }
303
+ async function addAnnotation(key, annotation) {
304
+ const s = await load();
305
+ const list = s.annotations[key] ?? [];
306
+ const next = { id: annotation.id ?? `an_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, createdAt: Date.now(), ...annotation };
307
+ list.push(next);
308
+ s.annotations[key] = list;
309
+ scheduleFlush();
310
+ return next;
311
+ }
312
+ async function patchAnnotation(key, id, patch) {
313
+ const s = await load();
314
+ const list = s.annotations[key] ?? [];
315
+ const idx = list.findIndex((a) => a.id === id);
316
+ if (idx === -1) return null;
317
+ list[idx] = { ...list[idx], ...patch, updatedAt: Date.now() };
318
+ scheduleFlush();
319
+ return list[idx];
320
+ }
321
+ async function removeAnnotation(key, id) {
322
+ const s = await load();
323
+ s.annotations[key] = (s.annotations[key] ?? []).filter((a) => a.id !== id);
324
+ scheduleFlush();
325
+ }
326
+ async function pruneFinishedTasks(olderThanMs = 24 * 3600 * 1e3) {
327
+ const s = await load();
328
+ const cutoff = Date.now() - olderThanMs;
329
+ let removed = 0;
330
+ for (const [id, t] of Object.entries(s.tasks)) {
331
+ if (t.state === "done" || t.state === "failed" || t.state === "cancelled") {
332
+ if ((t.updatedAt ?? 0) < cutoff) {
333
+ delete s.tasks[id];
334
+ removed += 1;
335
+ }
336
+ }
337
+ }
338
+ if (removed) scheduleFlush();
339
+ return removed;
340
+ }
341
+ async function init() {
342
+ await load();
343
+ await pruneFinishedTasks();
344
+ log("shadow store loaded");
345
+ }
346
+
347
+ // src/node/sse.js
348
+ init_http();
349
+ var clients = /* @__PURE__ */ new Set();
350
+ var counter = 0;
351
+ function addClient(res) {
352
+ clients.add(res);
353
+ return () => clients.delete(res);
354
+ }
355
+ function clientCount() {
356
+ return clients.size;
357
+ }
358
+ function emit(event, data) {
359
+ counter += 1;
360
+ for (const res of clients) {
361
+ if (res.writableEnded || res.destroyed) {
362
+ clients.delete(res);
363
+ continue;
364
+ }
365
+ try {
366
+ writeSseEvent(res, counter, event, data);
367
+ } catch (e) {
368
+ warn("sse write failed, dropping client:", e.message);
369
+ clients.delete(res);
370
+ }
371
+ }
372
+ }
373
+ function emitItem(item) {
374
+ emit("item", item);
375
+ }
376
+ function emitTask(task) {
377
+ emit("task", task);
378
+ }
379
+ function startHeartbeat(intervalMs = 2e4) {
380
+ const timer = setInterval(() => {
381
+ for (const res of clients) {
382
+ if (res.writableEnded || res.destroyed) {
383
+ clients.delete(res);
384
+ continue;
385
+ }
386
+ try {
387
+ res.write(": keep-alive\n\n");
388
+ } catch {
389
+ clients.delete(res);
390
+ }
391
+ }
392
+ }, intervalMs);
393
+ timer.unref?.();
394
+ return () => clearInterval(timer);
395
+ }
396
+
397
+ // src/node/routes.js
398
+ init_http();
399
+ import { readFile as readFile3 } from "node:fs/promises";
400
+ import { resolve as resolve4 } from "node:path";
401
+
402
+ // src/node/pipeline.js
403
+ import { randomUUID as randomUUID2 } from "node:crypto";
404
+ import { writeFile as writeFile3, unlink, mkdir as mkdir3 } from "node:fs/promises";
405
+ import { join as join4, dirname as dirname2 } from "node:path";
406
+
407
+ // src/node/extract/identifiers.js
408
+ var TRIM_RIGHT = /[.,;:!?。、,;:!?…—>'">)》\]]+$/;
409
+ var CJK_PUNCT = "\u3002\u3001\uFF0C\uFF1B\uFF1A\uFF01\uFF1F\uFF08\uFF09\u3010\u3011\u300A\u300B\u3008\u3009\u2026\u2014\uFF5E\xB7";
410
+ function trimRight(s) {
411
+ let out = s;
412
+ for (; ; ) {
413
+ const next = out.replace(TRIM_RIGHT, "");
414
+ const last = next[next.length - 1];
415
+ if ((last === ")" || last === "]" || last === "}" || last === "\uFF09" || last === "\u3011") && !next.slice(0, -1).includes(last === ")" ? "(" : last === "]" ? "[" : last === "}" ? "{" : last === "\uFF09" ? "\uFF08" : "\u3010")) {
416
+ out = next;
417
+ continue;
418
+ }
419
+ if (next === out) return next;
420
+ out = next;
421
+ }
422
+ }
423
+ var DOI_TAIL = `[^\\s"'<>\`|${CJK_PUNCT}]+`;
424
+ var DOI_CORE = new RegExp(`10\\.\\d{4,9}\\/${DOI_TAIL}`, "gi");
425
+ var DOI_HINT = new RegExp(`(?:https?:\\/\\/)?(?:dx\\.)?doi\\.org\\/(10\\.\\d{4,9}\\/${DOI_TAIL})`, "gi");
426
+ var DOI_LABEL = new RegExp(`\\bDOI\\s*[:\uFF1A]\\s*(10\\.\\d{4,9}\\/${DOI_TAIL})`, "gi");
427
+ var ARXIV_NEW = /\barXiv\s*[:. ]?\s*(\d{4}\.\d{4,5})(v\d+)?\b/gi;
428
+ var ARXIV_OLD = /\barXiv\s*[:. ]?\s*([a-z][a-z-]*(?:\.[A-Z]{2})?\/\d{7})(v\d+)?\b/gi;
429
+ var ARXIV_URL = /arxiv\.org\/(?:abs|pdf)\/([^\s"'?#>]+?)(?:\.pdf)?(?=[\s"'?#>()]|$)/gi;
430
+ var PMID = /\bPMID\s*[::]?\s*(\d{1,8})\b/gi;
431
+ var ISBN = /\bISBN(?:-1[03])?\s*[::]?\s*((?:97[89][-\s]?)?(?:\d[-\s]?){9}[\dXx])\b/gi;
432
+ var QUOTED = /[“"「『]([^”"」』\n]{12,300})[”"」』]/g;
433
+ var QUOTED_FALLBACK = /[‘'《]([^’'》\n]{12,300})[’'》]/g;
434
+ function push(list, seen, entry) {
435
+ const dedupeKey = `${entry.kind}:${entry.value.toLowerCase()}`;
436
+ if (seen.has(dedupeKey)) return;
437
+ seen.add(dedupeKey);
438
+ list.push(entry);
439
+ }
440
+ function extractIdentifiers(text, options = {}) {
441
+ const src = typeof text === "string" ? text : "";
442
+ if (!src.trim()) return [];
443
+ const out = [];
444
+ const seen = /* @__PURE__ */ new Set();
445
+ const linkTargets = [];
446
+ src.replace(/\]\(([^)\s]+)\)/g, (m, url) => {
447
+ linkTargets.push(url);
448
+ return m;
449
+ });
450
+ const scan = (source, offset = 0, confidenceBonus = 0) => {
451
+ let m;
452
+ DOI_HINT.lastIndex = 0;
453
+ while ((m = DOI_HINT.exec(source)) !== null) {
454
+ push(out, seen, {
455
+ kind: "doi",
456
+ value: trimRight(m[1]),
457
+ display: trimRight(m[1]),
458
+ index: offset + m.index,
459
+ confidence: 0.98 + confidenceBonus
460
+ });
461
+ }
462
+ DOI_LABEL.lastIndex = 0;
463
+ while ((m = DOI_LABEL.exec(source)) !== null) {
464
+ push(out, seen, {
465
+ kind: "doi",
466
+ value: trimRight(m[1]),
467
+ display: trimRight(m[1]),
468
+ index: offset + m.index,
469
+ confidence: 0.98 + confidenceBonus
470
+ });
471
+ }
472
+ DOI_CORE.lastIndex = 0;
473
+ while ((m = DOI_CORE.exec(source)) !== null) {
474
+ push(out, seen, {
475
+ kind: "doi",
476
+ value: trimRight(m[0]),
477
+ display: trimRight(m[0]),
478
+ index: offset + m.index,
479
+ // A bare DOI in prose is still very likely real, just slightly riskier.
480
+ confidence: 0.9 + confidenceBonus
481
+ });
482
+ }
483
+ ARXIV_URL.lastIndex = 0;
484
+ while ((m = ARXIV_URL.exec(source)) !== null) {
485
+ push(out, seen, {
486
+ kind: "arxiv",
487
+ value: trimRight(m[1]).replace(/\.pdf$/i, ""),
488
+ display: trimRight(m[1]).replace(/\.pdf$/i, ""),
489
+ index: offset + m.index,
490
+ confidence: 0.97 + confidenceBonus
491
+ });
492
+ }
493
+ ARXIV_NEW.lastIndex = 0;
494
+ while ((m = ARXIV_NEW.exec(source)) !== null) {
495
+ push(out, seen, {
496
+ kind: "arxiv",
497
+ value: m[1] + (m[2] ?? ""),
498
+ display: m[1] + (m[2] ?? ""),
499
+ index: offset + m.index,
500
+ confidence: 0.95 + confidenceBonus
501
+ });
502
+ }
503
+ ARXIV_OLD.lastIndex = 0;
504
+ while ((m = ARXIV_OLD.exec(source)) !== null) {
505
+ push(out, seen, {
506
+ kind: "arxiv",
507
+ value: m[1] + (m[2] ?? ""),
508
+ display: m[1] + (m[2] ?? ""),
509
+ index: offset + m.index,
510
+ confidence: 0.95 + confidenceBonus
511
+ });
512
+ }
513
+ PMID.lastIndex = 0;
514
+ while ((m = PMID.exec(source)) !== null) {
515
+ push(out, seen, { kind: "pmid", value: m[1], display: m[1], index: offset + m.index, confidence: 0.9 + confidenceBonus });
516
+ }
517
+ ISBN.lastIndex = 0;
518
+ while ((m = ISBN.exec(source)) !== null) {
519
+ push(out, seen, { kind: "isbn", value: m[1].replace(/[-\s]/g, ""), display: m[1], index: offset + m.index, confidence: 0.85 + confidenceBonus });
520
+ }
521
+ };
522
+ scan(src);
523
+ for (const url of linkTargets) scan(url, 0, 0.02);
524
+ if (options.includeTitles !== false) {
525
+ for (const re of [QUOTED, QUOTED_FALLBACK]) {
526
+ re.lastIndex = 0;
527
+ let m;
528
+ while ((m = re.exec(src)) !== null) {
529
+ const value = m[1].trim();
530
+ if (/^10\.\d{4,9}\//.test(value)) continue;
531
+ if (!/[一-龥A-Za-z]/.test(value)) continue;
532
+ push(out, seen, { kind: "title", value, display: value, index: m.index, confidence: 0.45 });
533
+ }
534
+ }
535
+ }
536
+ return out.sort((a, b) => a.index - b.index);
537
+ }
538
+
539
+ // src/node/extract/dedupe.js
540
+ function normalizeDoi(value) {
541
+ if (!value) return "";
542
+ return String(value).trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, "").replace(/^doi:\s*/i, "").replace(/[.,;。;]$/, "").toLowerCase();
543
+ }
544
+ function normalizeArxiv(value) {
545
+ if (!value) return "";
546
+ return String(value).trim().replace(/^https?:\/\/arxiv\.org\/(abs|pdf)\//i, "").replace(/\.pdf$/i, "").replace(/^arxiv:\s*/i, "").toLowerCase();
547
+ }
548
+ function arxivBase(value) {
549
+ return normalizeArxiv(value).replace(/v\d+$/, "");
550
+ }
551
+ function normalizeIsbn(value) {
552
+ return String(value ?? "").replace(/[-\s]/g, "").toUpperCase();
553
+ }
554
+ function titleFingerprint(title) {
555
+ if (!title) return "";
556
+ return String(title).toLowerCase().replace(/[‘’“”「」『』《》〈〉"'`]/g, "").replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, " ").trim().replace(/\s+/g, " ").slice(0, 80);
557
+ }
558
+ function identityKey({ doi, arxiv, isbn, pmid, title }) {
559
+ const d = normalizeDoi(doi);
560
+ if (d) return `doi:${d}`;
561
+ const a = arxivBase(arxiv);
562
+ if (a) return `arxiv:${a}`;
563
+ const i = normalizeIsbn(isbn);
564
+ if (i) return `isbn:${i}`;
565
+ if (pmid) return `pmid:${String(pmid)}`;
566
+ const t = titleFingerprint(title);
567
+ if (t) return `title:${t}`;
568
+ return "";
569
+ }
570
+ function aliasKeys({ doi, arxiv, isbn, pmid, title }) {
571
+ const keys = /* @__PURE__ */ new Set();
572
+ const d = normalizeDoi(doi);
573
+ const a = arxivBase(arxiv);
574
+ const i = normalizeIsbn(isbn);
575
+ const t = titleFingerprint(title);
576
+ if (d) keys.add(`doi:${d}`);
577
+ if (a) keys.add(`arxiv:${a}`);
578
+ if (i) keys.add(`isbn:${i}`);
579
+ if (pmid) keys.add(`pmid:${String(pmid)}`);
580
+ if (t) keys.add(`title:${t}`);
581
+ return [...keys];
582
+ }
583
+ function buildItem(meta) {
584
+ const doi = normalizeDoi(meta.doi) || "";
585
+ const arxiv = arxivBase(meta.arxiv) || "";
586
+ const isbn = normalizeIsbn(meta.isbn) || "";
587
+ const pmid = meta.pmid ? String(meta.pmid) : "";
588
+ const title = (meta.title ?? "").trim();
589
+ const base = { doi, arxiv, isbn, pmid, title };
590
+ const key = identityKey(base);
591
+ return {
592
+ key,
593
+ aliases: aliasKeys(base),
594
+ ...base,
595
+ authors: meta.authors ?? [],
596
+ year: meta.year ?? null,
597
+ container: meta.container ?? "",
598
+ publisher: meta.publisher ?? "",
599
+ abstract: meta.abstract ?? "",
600
+ url: meta.url ?? "",
601
+ itemType: meta.itemType ?? "journalArticle",
602
+ raw: meta.raw ?? meta
603
+ };
604
+ }
605
+ function sameWork(a, b) {
606
+ const ka = a.aliases ?? aliasKeys(a);
607
+ const kb = b.aliases ?? aliasKeys(b);
608
+ return ka.some((k) => kb.includes(k));
609
+ }
610
+
611
+ // src/node/net.js
612
+ var UA = "dsh-literature/0.1 (+https://github.com/deepseek-ai/deepseek-harness)";
613
+ var FetchFailure = class extends Error {
614
+ constructor(message, { code = "network", status = 0, retryable = true, cause } = {}) {
615
+ super(message, { cause });
616
+ this.name = "FetchFailure";
617
+ this.code = code;
618
+ this.status = status;
619
+ this.retryable = retryable;
620
+ }
621
+ };
622
+ async function httpGet(url, { timeoutMs = 3e4, headers = {}, accept, signal } = {}) {
623
+ const controller = new AbortController();
624
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
625
+ const onAbort = () => controller.abort();
626
+ if (signal) {
627
+ if (signal.aborted) controller.abort();
628
+ else signal.addEventListener("abort", onAbort, { once: true });
629
+ }
630
+ try {
631
+ const res = await fetch(url, {
632
+ method: "GET",
633
+ redirect: "follow",
634
+ signal: controller.signal,
635
+ headers: { "user-agent": UA, ...accept ? { accept } : {}, ...headers }
636
+ });
637
+ if (!res.ok) {
638
+ throw new FetchFailure(`GET ${url} -> HTTP ${res.status}`, {
639
+ code: res.status === 404 ? "not_found" : res.status === 403 || res.status === 401 ? "forbidden" : "network",
640
+ status: res.status,
641
+ retryable: res.status === 429 || res.status >= 500
642
+ });
643
+ }
644
+ return res;
645
+ } catch (e) {
646
+ if (e instanceof FetchFailure) throw e;
647
+ if (e?.name === "AbortError") {
648
+ throw new FetchFailure(`GET ${url} timed out after ${timeoutMs}ms`, { code: "timeout", retryable: true, cause: e });
649
+ }
650
+ throw new FetchFailure(`GET ${url} failed: ${e?.message ?? e}`, { code: "network", retryable: true, cause: e });
651
+ } finally {
652
+ clearTimeout(timer);
653
+ if (signal) signal.removeEventListener("abort", onAbort);
654
+ }
655
+ }
656
+ async function httpGetJson(url, options = {}) {
657
+ const res = await httpGet(url, { accept: "application/json", ...options });
658
+ try {
659
+ return await res.json();
660
+ } catch (e) {
661
+ throw new FetchFailure(`GET ${url} returned non-JSON body`, { code: "bad_payload", retryable: false, cause: e });
662
+ }
663
+ }
664
+ async function httpGetText(url, options = {}) {
665
+ const res = await httpGet(url, options);
666
+ return res.text();
667
+ }
668
+ async function httpGetBuffer(url, options = {}) {
669
+ const res = await httpGet(url, options);
670
+ const buf = Buffer.from(await res.arrayBuffer());
671
+ return { buffer: buf, contentType: res.headers.get("content-type") ?? "", finalUrl: res.url || url };
672
+ }
673
+ async function withRetry(fn, { maxAttempts = 3, baseDelayMs = 800, maxDelayMs = 8e3, label = "operation", shouldRetry } = {}) {
674
+ let lastError;
675
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
676
+ try {
677
+ return await fn(attempt);
678
+ } catch (e) {
679
+ lastError = e;
680
+ const retryable = shouldRetry ? shouldRetry(e) : e?.retryable !== false;
681
+ if (!retryable || attempt >= maxAttempts) break;
682
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
683
+ const jitter = Math.round(delay * 0.2 * Math.random());
684
+ warn(`${label} attempt ${attempt}/${maxAttempts} failed (${e.message}); retrying in ${delay + jitter}ms`);
685
+ await new Promise((r) => setTimeout(r, delay + jitter));
686
+ }
687
+ }
688
+ throw lastError;
689
+ }
690
+
691
+ // src/node/metadata/crossref.js
692
+ var BASE = "https://api.crossref.org";
693
+ function splitName(name2) {
694
+ const s = (name2 ?? "").trim();
695
+ if (!s) return { firstName: "", lastName: "" };
696
+ if (s.includes(",")) {
697
+ const [last, first] = s.split(",").map((p) => p.trim());
698
+ return { firstName: first ?? "", lastName: last ?? "" };
699
+ }
700
+ const parts = s.split(/\s+/);
701
+ if (parts.length === 1) return { firstName: "", lastName: parts[0] };
702
+ return { firstName: parts.slice(0, -1).join(" "), lastName: parts[parts.length - 1] };
703
+ }
704
+ function yearOf(work) {
705
+ const parts = work.issued?.["date-parts"]?.[0];
706
+ if (Array.isArray(parts) && Number.isFinite(parts[0])) return parts[0];
707
+ const fromPrint = work["published-print"]?.["date-parts"]?.[0];
708
+ if (Array.isArray(fromPrint) && Number.isFinite(fromPrint[0])) return fromPrint[0];
709
+ const fromOnline = work["published-online"]?.["date-parts"]?.[0];
710
+ if (Array.isArray(fromOnline) && Number.isFinite(fromOnline[0])) return fromOnline[0];
711
+ return null;
712
+ }
713
+ function cleanText(s) {
714
+ return String(s ?? "").replace(/<jats:[^>]*>|<\/jats:[^>]*>/g, "").replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim();
715
+ }
716
+ var TYPE_MAP = {
717
+ "journal-article": "journalArticle",
718
+ "proceedings-article": "conferencePaper",
719
+ book: "book",
720
+ "book-chapter": "bookSection",
721
+ "book-part": "bookSection",
722
+ monograph: "book",
723
+ "edited-book": "book",
724
+ "reference-book": "book",
725
+ report: "report",
726
+ "report-component": "report",
727
+ dissertation: "thesis",
728
+ preprint: "preprint",
729
+ "posted-content": "preprint",
730
+ dataset: "dataset",
731
+ standard: "standard",
732
+ "journal-issue": "journalArticle",
733
+ "peer-review": "journalArticle"
734
+ };
735
+ function normalizeWork(work) {
736
+ if (!work) return null;
737
+ const authors2 = (work.author ?? []).map((a) => ({
738
+ creatorType: "author",
739
+ ...splitName(a.name ?? [a.given, a.family].filter(Boolean).join(" "))
740
+ }));
741
+ const editors = (work.editor ?? []).map((a) => ({
742
+ creatorType: "editor",
743
+ ...splitName(a.name ?? [a.given, a.family].filter(Boolean).join(" "))
744
+ }));
745
+ return {
746
+ source: "crossref",
747
+ itemType: TYPE_MAP[work.type] ?? "journalArticle",
748
+ title: Array.isArray(work.title) ? cleanText(work.title[0]) : cleanText(work.title),
749
+ authors: authors2.length ? authors2 : editors,
750
+ year: yearOf(work),
751
+ container: Array.isArray(work["container-title"]) ? cleanText(work["container-title"][0]) : cleanText(work["container-title"]),
752
+ publisher: cleanText(work.publisher),
753
+ volume: work.volume ?? "",
754
+ issue: work.issue ?? "",
755
+ pages: work.page ?? "",
756
+ doi: work.DOI ?? "",
757
+ isbn: Array.isArray(work.ISBN) ? work.ISBN[0] ?? "" : work.ISBN ?? "",
758
+ issn: Array.isArray(work.ISSN) ? work.ISSN[0] ?? "" : work.ISSN ?? "",
759
+ url: work.URL ?? (work.DOI ? `https://doi.org/${work.DOI}` : ""),
760
+ abstract: cleanText(work.abstract),
761
+ language: work.language ?? "",
762
+ raw: work
763
+ };
764
+ }
765
+ async function fetchByDoi(doi, { mailto, timeoutMs } = {}) {
766
+ const url = `${BASE}/works/${encodeURIComponent(doi.replace(/^https?:\/\/(dx\.)?doi\.org\//i, ""))}${mailto ? `?mailto=${encodeURIComponent(mailto)}` : ""}`;
767
+ const body = await httpGetJson(url, { timeoutMs });
768
+ return normalizeWork(body.message);
769
+ }
770
+ async function searchByTitle(title, { mailto, timeoutMs, rows = 3 } = {}) {
771
+ const params = new URLSearchParams({ "query.bibliographic": title, rows: String(rows), select: "DOI,title,author,issued,type,container-title,publisher,volume,issue,page,ISSN,ISBN,URL,abstract" });
772
+ if (mailto) params.set("mailto", mailto);
773
+ const body = await httpGetJson(`${BASE}/works?${params}`, { timeoutMs });
774
+ const items = (body.message?.items ?? []).map(normalizeWork).filter(Boolean);
775
+ if (!items.length) return [];
776
+ const wanted = title.toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, " ").trim();
777
+ return items.sort((a, b) => {
778
+ const fa = (a.title ?? "").toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, " ").trim();
779
+ const fb = (b.title ?? "").toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, " ").trim();
780
+ const da = fa && wanted && (fa.includes(wanted) || wanted.includes(fa)) ? 0 : 1;
781
+ const db = fb && wanted && (fb.includes(wanted) || wanted.includes(fb)) ? 0 : 1;
782
+ return da - db;
783
+ });
784
+ }
785
+
786
+ // src/node/metadata/arxiv.js
787
+ var API = "https://export.arxiv.org/api/query";
788
+ function decodeEntities(s) {
789
+ return String(s ?? "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, "&");
790
+ }
791
+ function tag(block, name2) {
792
+ const m = new RegExp(`<${name2}(?:\\s[^>]*)?>([\\s\\S]*?)</${name2}>`, "i").exec(block);
793
+ return m ? decodeEntities(m[1]).replace(/\s+/g, " ").trim() : "";
794
+ }
795
+ function namespacesTag(block, name2) {
796
+ const m = new RegExp(`<[a-zA-Z-]+:${name2}(?:\\s[^>]*)?>([\\s\\S]*?)</[a-zA-Z-]+:${name2}>`, "i").exec(block);
797
+ return m ? decodeEntities(m[1]).trim() : "";
798
+ }
799
+ function authors(block) {
800
+ const out = [];
801
+ const re = /<author>([\s\S]*?)<\/author>/gi;
802
+ let m;
803
+ while ((m = re.exec(block)) !== null) {
804
+ const name2 = tag(m[1], "name");
805
+ if (!name2) continue;
806
+ const parts = name2.split(/\s+/);
807
+ out.push({
808
+ creatorType: "author",
809
+ firstName: parts.length > 1 ? parts.slice(0, -1).join(" ") : "",
810
+ lastName: parts[parts.length - 1]
811
+ });
812
+ }
813
+ return out;
814
+ }
815
+ function pdfUrl(block, entryId) {
816
+ const direct = /<link[^>]*title=["']pdf["'][^>]*href=["']([^"']+)["']/i.exec(block);
817
+ if (direct) return decodeEntities(direct[1]);
818
+ const byType = /<link[^>]*type=["']application\/pdf["'][^>]*href=["']([^"']+)["']/i.exec(block);
819
+ if (byType) return decodeEntities(byType[1]);
820
+ const clean = String(entryId ?? "").replace(/\/v\d+$/, "");
821
+ return clean ? `${clean.replace("http://", "https://")}.pdf` : "";
822
+ }
823
+ function normalizeEntry(block) {
824
+ const id = tag(block, "id");
825
+ const versioned = /abs\/(.+?)(?:v\d+)?$/i.exec(id)?.[1] ?? "";
826
+ const title = tag(block, "title");
827
+ if (!title) return null;
828
+ const published = tag(block, "published");
829
+ const year = published ? Number(published.slice(0, 4)) : null;
830
+ const journalRef = namespacesTag(block, "journal_ref");
831
+ return {
832
+ source: "arxiv",
833
+ itemType: journalRef ? "journalArticle" : "preprint",
834
+ title,
835
+ authors: authors(block),
836
+ year: Number.isFinite(year) ? year : null,
837
+ container: journalRef ? "" : "arXiv",
838
+ publisher: journalRef ? "" : "arXiv",
839
+ volume: "",
840
+ issue: "",
841
+ pages: "",
842
+ doi: namespacesTag(block, "doi") || "",
843
+ isbn: "",
844
+ issn: "",
845
+ url: id,
846
+ abstract: tag(block, "summary"),
847
+ arxiv: versioned,
848
+ pdfUrl: pdfUrl(block, id),
849
+ raw: { id, published, journalRef }
850
+ };
851
+ }
852
+ async function fetchById(id, { timeoutMs } = {}) {
853
+ const clean = String(id).replace(/^arxiv:/i, "").replace(/\.pdf$/i, "");
854
+ const url = `${API}?id_list=${encodeURIComponent(clean)}&max_results=1`;
855
+ const xml = await httpGetText(url, { timeoutMs });
856
+ const entry = /<entry>([\s\S]*?)<\/entry>/i.exec(xml);
857
+ if (!entry) return null;
858
+ return normalizeEntry(entry[1]);
859
+ }
860
+ async function searchByTitle2(title, { timeoutMs, rows = 3 } = {}) {
861
+ const url = `${API}?search_query=${encodeURIComponent(`all:"${title}"`)}&max_results=${rows}&sortBy=relevance`;
862
+ const xml = await httpGetText(url, { timeoutMs });
863
+ const out = [];
864
+ const re = /<entry>([\s\S]*?)<\/entry>/gi;
865
+ let m;
866
+ while ((m = re.exec(xml)) !== null) {
867
+ const e = normalizeEntry(m[1]);
868
+ if (e) out.push(e);
869
+ }
870
+ return out;
871
+ }
872
+
873
+ // src/node/metadata/openalex.js
874
+ var BASE2 = "https://api.openalex.org";
875
+ var TYPE_MAP2 = {
876
+ article: "journalArticle",
877
+ book: "book",
878
+ "book-chapter": "bookSection",
879
+ editorial: "journalArticle",
880
+ letter: "journalArticle",
881
+ preprint: "preprint",
882
+ dataset: "dataset",
883
+ review: "journalArticle",
884
+ dissertation: "thesis",
885
+ "peer-review": "journalArticle",
886
+ "reference-entry": "journalArticle"
887
+ };
888
+ function reconstructAbstract(inverted) {
889
+ if (!inverted || typeof inverted !== "object") return "";
890
+ const slots = [];
891
+ for (const [word, positions] of Object.entries(inverted)) {
892
+ for (const p of positions) slots[p] = word;
893
+ }
894
+ return slots.filter(Boolean).join(" ").trim();
895
+ }
896
+ function normalizeWork2(work) {
897
+ if (!work) return null;
898
+ const authors2 = (work.authorships ?? []).map((a) => {
899
+ const name2 = (a.author?.display_name ?? "").trim();
900
+ const parts = name2.split(/\s+/);
901
+ return {
902
+ creatorType: "author",
903
+ firstName: parts.length > 1 ? parts.slice(0, -1).join(" ") : "",
904
+ lastName: parts[parts.length - 1] ?? name2
905
+ };
906
+ });
907
+ const loc = work.primary_location ?? work.best_oa_location ?? {};
908
+ const venue = loc.source ?? {};
909
+ const oa = work.best_oa_location ?? {};
910
+ const biblio = work.biblio ?? {};
911
+ return {
912
+ source: "openalex",
913
+ itemType: TYPE_MAP2[work.type] ?? "journalArticle",
914
+ title: (work.title ?? work.display_name ?? "").trim(),
915
+ authors: authors2,
916
+ year: work.publication_year ?? null,
917
+ container: venue.display_name ?? "",
918
+ publisher: venue.host_organization_name ?? "",
919
+ volume: biblio.volume ?? "",
920
+ issue: biblio.issue ?? "",
921
+ pages: [biblio.first_page, biblio.last_page].filter(Boolean).join("-"),
922
+ doi: (work.doi ?? "").replace(/^https?:\/\/(dx\.)?doi\.org\//i, ""),
923
+ isbn: Array.isArray(work.isbns) ? work.isbns[0] ?? "" : "",
924
+ issn: Array.isArray(venue.issn) ? venue.issn[0] ?? "" : venue.issn_l ?? "",
925
+ url: work.doi ?? work.id ?? "",
926
+ abstract: reconstructAbstract(work.abstract_inverted_index),
927
+ language: work.language_code ?? "",
928
+ openAccess: {
929
+ isOa: !!work.open_access?.is_oa,
930
+ pdfUrl: oa.pdf_url ?? "",
931
+ landingPageUrl: oa.landing_page_url ?? ""
932
+ },
933
+ raw: work
934
+ };
935
+ }
936
+ async function fetchByDoi2(doi, { mailto, timeoutMs } = {}) {
937
+ const clean = String(doi).replace(/^https?:\/\/(dx\.)?doi\.org\//i, "");
938
+ const url = `${BASE2}/works/doi:${encodeURIComponent(clean)}${mailto ? `?mailto=${encodeURIComponent(mailto)}` : ""}`;
939
+ try {
940
+ return normalizeWork2(await httpGetJson(url, { timeoutMs }));
941
+ } catch (e) {
942
+ if (e?.status === 404) return null;
943
+ throw e;
944
+ }
945
+ }
946
+ async function searchByTitle3(title, { mailto, timeoutMs, rows = 3 } = {}) {
947
+ const params = new URLSearchParams({ search: title, per_page: String(rows) });
948
+ if (mailto) params.set("mailto", mailto);
949
+ const body = await httpGetJson(`${BASE2}/works?${params}`, { timeoutMs });
950
+ return (body.results ?? []).map(normalizeWork2).filter(Boolean);
951
+ }
952
+
953
+ // src/node/metadata/index.js
954
+ var ID_CONVERTER = "https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/";
955
+ async function pmidToDoi(pmid, timeoutMs) {
956
+ try {
957
+ const body = await httpGetJson(`${ID_CONVERTER}?ids=${encodeURIComponent(pmid)}&format=json`, { timeoutMs });
958
+ const rec = body?.records?.[0];
959
+ return rec?.doi ?? "";
960
+ } catch (e) {
961
+ warn("pmid -> doi conversion failed:", e.message);
962
+ return "";
963
+ }
964
+ }
965
+ async function firstResult(fn, label) {
966
+ try {
967
+ return await fn();
968
+ } catch (e) {
969
+ warn(`${label} lookup failed:`, e.message);
970
+ return null;
971
+ }
972
+ }
973
+ async function resolveIdentifier(id, { timeoutMs = 2e4, unpaywallEmail = "" } = {}) {
974
+ const mailto = unpaywallEmail || void 0;
975
+ if (id.kind === "doi") {
976
+ return await firstResult(() => fetchByDoi(id.value, { mailto, timeoutMs }), "crossref/doi") ?? await firstResult(() => fetchByDoi2(id.value, { mailto, timeoutMs }), "openalex/doi");
977
+ }
978
+ if (id.kind === "arxiv") {
979
+ const direct = await firstResult(() => fetchById(id.value, { timeoutMs }), "arxiv/id");
980
+ if (direct) {
981
+ if (direct.doi) {
982
+ const published = await firstResult(() => fetchByDoi(direct.doi, { mailto, timeoutMs }), "crossref/doi");
983
+ if (published) return { ...published, arxiv: direct.arxiv, pdfUrl: direct.pdfUrl, preprint: direct };
984
+ }
985
+ return direct;
986
+ }
987
+ return null;
988
+ }
989
+ if (id.kind === "pmid") {
990
+ const doi = await pmidToDoi(id.value, timeoutMs);
991
+ if (doi) {
992
+ const rec = await firstResult(() => fetchByDoi(doi, { mailto, timeoutMs }), "crossref/doi");
993
+ if (rec) return { ...rec, pmid: id.value };
994
+ }
995
+ return null;
996
+ }
997
+ if (id.kind === "isbn") {
998
+ return (await firstResult(() => searchByTitle3(id.value, { mailto, timeoutMs, rows: 1 }), "openalex/isbn"))?.[0] ?? null;
999
+ }
1000
+ if (id.kind === "title") {
1001
+ const cr = await firstResult(() => searchByTitle(id.value, { mailto, timeoutMs, rows: 3 }), "crossref/title");
1002
+ if (cr?.length) return cr[0];
1003
+ const ax = await firstResult(() => searchByTitle2(id.value, { timeoutMs, rows: 3 }), "arxiv/title");
1004
+ if (ax?.length) return ax[0];
1005
+ const oa = await firstResult(() => searchByTitle3(id.value, { mailto, timeoutMs, rows: 3 }), "openalex/title");
1006
+ if (oa?.length) return oa[0];
1007
+ return null;
1008
+ }
1009
+ return null;
1010
+ }
1011
+
1012
+ // src/node/metadata/normalize.js
1013
+ var BASE_FIELDS = ["title", "abstractNote", "shortTitle", "url", "accessDate", "language", "rights", "extra", "DOI"];
1014
+ var TYPE_FIELDS = {
1015
+ journalArticle: ["publicationTitle", "journalAbbreviation", "volume", "issue", "pages", "ISSN"],
1016
+ book: ["publisher", "place", "edition", "numberOfVolumes", "ISBN", "series", "seriesNumber"],
1017
+ bookSection: ["bookTitle", "publisher", "place", "edition", "pages", "ISBN", "series", "seriesNumber"],
1018
+ conferencePaper: ["conferenceName", "proceedingsTitle", "publisher", "place", "pages", "DOI"],
1019
+ preprint: ["repository", "archiveID", "publisher", "DOI"],
1020
+ thesis: ["thesisType", "university", "place"],
1021
+ report: ["reportNumber", "reportType", "institution", "place"],
1022
+ dataset: ["repository", "publisher", "versionNumber"],
1023
+ standard: ["organization", "publisher", "place"]
1024
+ };
1025
+ function cleanStr(v) {
1026
+ if (v === null || v === void 0) return "";
1027
+ return String(v).replace(/\s+/g, " ").trim();
1028
+ }
1029
+ function isoDate(record) {
1030
+ if (record.year) return String(record.year);
1031
+ const raw = cleanStr(record.date);
1032
+ return raw;
1033
+ }
1034
+ function toZoteroItem(record, { clientId, tags = [], extra = "" } = {}) {
1035
+ const itemType = TYPE_FIELDS[record.itemType] ? record.itemType : "journalArticle";
1036
+ const allowed = /* @__PURE__ */ new Set([...BASE_FIELDS, ...TYPE_FIELDS[itemType]]);
1037
+ const item = {
1038
+ id: clientId,
1039
+ itemType,
1040
+ title: cleanStr(record.title) || "Untitled",
1041
+ creators: (record.authors ?? []).filter((a) => a && (a.lastName || a.firstName)).map((a) => ({
1042
+ creatorType: a.creatorType ?? "author",
1043
+ firstName: cleanStr(a.firstName),
1044
+ lastName: cleanStr(a.lastName)
1045
+ }))
1046
+ };
1047
+ const set = (field, value) => {
1048
+ if (!allowed.has(field)) return;
1049
+ const v = cleanStr(value);
1050
+ if (v) item[field] = v;
1051
+ };
1052
+ set("abstractNote", record.abstract);
1053
+ set("DOI", record.doi);
1054
+ set("ISBN", record.isbn);
1055
+ set("ISSN", record.issn);
1056
+ set("url", record.url);
1057
+ set("language", record.language);
1058
+ set("publisher", record.publisher);
1059
+ set("volume", record.volume);
1060
+ set("issue", record.issue);
1061
+ set("pages", record.pages);
1062
+ set("date", isoDate(record));
1063
+ if (itemType === "journalArticle") set("publicationTitle", record.container);
1064
+ if (itemType === "bookSection") set("bookTitle", record.container);
1065
+ if (itemType === "conferencePaper") set("proceedingsTitle", record.container);
1066
+ if (itemType === "preprint") {
1067
+ set("repository", record.container || "arXiv");
1068
+ set("archiveID", record.arxiv);
1069
+ }
1070
+ if (itemType === "dataset") set("repository", record.container);
1071
+ item.accessDate = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d+Z$/, "Z");
1072
+ const extraParts = [];
1073
+ if (record.arxiv) extraParts.push(`arXiv:${record.arxiv}`);
1074
+ if (record.pmid) extraParts.push(`PMID: ${record.pmid}`);
1075
+ if (extra) extraParts.push(extra);
1076
+ if (extraParts.length) item.extra = extraParts.join("\n");
1077
+ if (tags.length) item.tags = tags.map((t) => ({ tag: t }));
1078
+ item.attachments = [];
1079
+ return item;
1080
+ }
1081
+ function shortLabel(record) {
1082
+ const authors2 = record.authors ?? [];
1083
+ const first = authors2[0]?.lastName ?? "";
1084
+ const suffix = authors2.length > 1 ? " \u7B49" : "";
1085
+ const who = first ? `${first}${suffix}` : record.container || "\u672A\u547D\u540D";
1086
+ const year = record.year ? ` ${record.year}` : "";
1087
+ return `${who}${year}`.trim();
1088
+ }
1089
+
1090
+ // src/node/fetch/pdf.js
1091
+ var PDF_MAGIC = Buffer.from("%PDF-", "latin1");
1092
+ var PdfFailure = class extends Error {
1093
+ constructor(message, code, { retryable = false, detail } = {}) {
1094
+ super(message);
1095
+ this.name = "PdfFailure";
1096
+ this.code = code;
1097
+ this.retryable = retryable;
1098
+ this.detail = detail;
1099
+ }
1100
+ };
1101
+ function looksLikePdf(buffer) {
1102
+ if (!buffer || buffer.length < 8) return false;
1103
+ return buffer.subarray(0, 5).equals(PDF_MAGIC);
1104
+ }
1105
+ async function candidates(record, { unpaywallEmail } = {}) {
1106
+ const out = [];
1107
+ const push2 = (url, source, kind = "pdf") => {
1108
+ if (!url) return;
1109
+ out.push({ url: String(url), source, kind });
1110
+ };
1111
+ if (record.arxiv) {
1112
+ const base = String(record.arxiv).replace(/v\d+$/, "");
1113
+ push2(`https://arxiv.org/pdf/${base}`, "arXiv", "pdf");
1114
+ }
1115
+ if (record.pdfUrl) push2(record.pdfUrl, record.source ?? "metadata");
1116
+ if (record.openAccess?.pdfUrl) push2(record.openAccess.pdfUrl, "OpenAlex OA", "pdf");
1117
+ const doi = record.doi;
1118
+ if (doi) {
1119
+ try {
1120
+ const oa = await httpGetJson(`https://api.openalex.org/works/doi:${encodeURIComponent(doi)}`, { timeoutMs: 1e4 });
1121
+ const best = oa.best_oa_location ?? {};
1122
+ push2(best.pdf_url, "OpenAlex OA", "pdf");
1123
+ if (!best.pdf_url) push2(best.landing_page_url, "OpenAlex OA", "landing");
1124
+ } catch (e) {
1125
+ warn("openalex oa lookup failed:", e.message);
1126
+ }
1127
+ if (unpaywallEmail) {
1128
+ try {
1129
+ const body = await httpGetJson(`https://api.unpaywall.org/v2/${encodeURIComponent(doi)}?email=${encodeURIComponent(unpaywallEmail)}`, { timeoutMs: 15e3 });
1130
+ const best = body.best_oa_location;
1131
+ if (best) {
1132
+ push2(best.url_for_pdf, "Unpaywall", "pdf");
1133
+ push2(best.url, "Unpaywall", "landing");
1134
+ }
1135
+ for (const loc of body.oa_locations ?? []) {
1136
+ push2(loc.url_for_pdf, "Unpaywall", "pdf");
1137
+ }
1138
+ } catch (e) {
1139
+ warn("unpaywall lookup failed:", e.message);
1140
+ }
1141
+ }
1142
+ push2(`https://doi.org/${encodeURIComponent(doi)}`, "DOI resolution", "landing");
1143
+ try {
1144
+ const cr = await httpGetJson(`https://api.crossref.org/works/${encodeURIComponent(doi)}`, { timeoutMs: 15e3 });
1145
+ for (const link of cr.message?.link ?? []) {
1146
+ if (link["content-type"] === "application/pdf") push2(link.URL, "Crossref link", "pdf");
1147
+ }
1148
+ } catch (e) {
1149
+ warn("crossref link lookup failed:", e.message);
1150
+ }
1151
+ }
1152
+ const seen = /* @__PURE__ */ new Set();
1153
+ return out.filter((c) => {
1154
+ const k = c.url.toLowerCase();
1155
+ if (seen.has(k)) return false;
1156
+ seen.add(k);
1157
+ return true;
1158
+ });
1159
+ }
1160
+ function extractPdfUrlFromHtml(html, baseUrl2) {
1161
+ const re = /<meta[^>]+name=["']citation_pdf_url["'][^>]+content=["']([^"']+)["']/i.exec(html) ?? /<meta[^>]+content=["']([^"']+)["'][^>]+name=["']citation_pdf_url["']/i.exec(html);
1162
+ if (re) {
1163
+ try {
1164
+ return new URL(re[1], baseUrl2).href;
1165
+ } catch {
1166
+ }
1167
+ }
1168
+ const link = /<link[^>]+rel=["']alternate["'][^>]+type=["']application\/pdf["'][^>]+href=["']([^"']+)["']/i.exec(html);
1169
+ if (link) {
1170
+ try {
1171
+ return new URL(link[1], baseUrl2).href;
1172
+ } catch {
1173
+ }
1174
+ }
1175
+ return null;
1176
+ }
1177
+ async function fetchPdf(record, { timeoutMs = 3e4, unpaywallEmail = "" } = {}) {
1178
+ const list = await candidates(record, { unpaywallEmail });
1179
+ if (!list.length) {
1180
+ throw new PdfFailure("\u6CA1\u6709\u53EF\u7528\u7684\u5168\u6587\u6765\u6E90", "no_source", { retryable: false });
1181
+ }
1182
+ const failures = [];
1183
+ const pending = [...list];
1184
+ while (pending.length) {
1185
+ const cand = pending.shift();
1186
+ try {
1187
+ const { buffer, contentType, finalUrl } = await httpGetBuffer(cand.url, {
1188
+ timeoutMs,
1189
+ accept: "application/pdf,*/*;q=0.8"
1190
+ });
1191
+ if (looksLikePdf(buffer)) {
1192
+ log(`pdf ok: ${cand.url} (${(buffer.length / 1024).toFixed(0)} KB via ${cand.source})`);
1193
+ return { buffer, url: finalUrl || cand.url, source: cand.source };
1194
+ }
1195
+ const isHtml = /text\/html|application\/xhtml/i.test(contentType);
1196
+ if (isHtml) {
1197
+ const discovered = extractPdfUrlFromHtml(buffer.toString("utf8", 0, Math.min(buffer.length, 2 * 1024 * 1024)), finalUrl || cand.url);
1198
+ if (discovered) {
1199
+ pending.unshift({ url: discovered, source: `${cand.source} (PDF \u94FE\u63A5)`, kind: "pdf" });
1200
+ continue;
1201
+ }
1202
+ failures.push({ url: cand.url, source: cand.source, reason: "landing page, no discoverable PDF link" });
1203
+ continue;
1204
+ }
1205
+ failures.push({ url: cand.url, source: cand.source, reason: `unexpected content-type ${contentType || "unknown"}` });
1206
+ } catch (e) {
1207
+ if (e instanceof FetchFailure && e.code === "not_found") {
1208
+ failures.push({ url: cand.url, source: cand.source, reason: "404" });
1209
+ continue;
1210
+ }
1211
+ failures.push({ url: cand.url, source: cand.source, reason: e.message });
1212
+ }
1213
+ }
1214
+ const sawLandingOnly = failures.length > 0 && failures.every((f) => /landing page|404/.test(f.reason));
1215
+ throw new PdfFailure(
1216
+ sawLandingOnly ? "\u8BE5\u6587\u732E\u6CA1\u6709\u5F00\u653E\u83B7\u53D6\u5168\u6587\uFF08\u53EF\u80FD\u662F\u4ED8\u8D39\u5899\uFF09" : "\u6240\u6709\u5168\u6587\u6765\u6E90\u5747\u4E0B\u8F7D\u5931\u8D25",
1217
+ sawLandingOnly ? "paywalled" : "network",
1218
+ { retryable: !sawLandingOnly, detail: failures }
1219
+ );
1220
+ }
1221
+
1222
+ // src/node/zotero/connector.js
1223
+ import { randomUUID } from "node:crypto";
1224
+
1225
+ // src/node/zotero/data-dir.js
1226
+ import { readFile as readFile2, readdir, stat } from "node:fs/promises";
1227
+ import { join as join2, resolve as resolve2 } from "node:path";
1228
+ import { homedir as homedir2, platform } from "node:os";
1229
+ function appDataRoot() {
1230
+ if (process.env.APPDATA) return process.env.APPDATA;
1231
+ if (platform() === "win32") return join2(homedir2(), "AppData", "Roaming");
1232
+ if (platform() === "darwin") return join2(homedir2(), "Library", "Application Support");
1233
+ return join2(homedir2(), ".config");
1234
+ }
1235
+ function zoteroProfileRoot() {
1236
+ return join2(appDataRoot(), "Zotero", "Zotero");
1237
+ }
1238
+ function unescapePath(value) {
1239
+ return String(value).replace(/\\\\/g, "\\");
1240
+ }
1241
+ async function parseProfilesIni(root) {
1242
+ try {
1243
+ const ini = await readFile2(join2(root, "profiles.ini"), "utf8");
1244
+ const profiles = [];
1245
+ let current = null;
1246
+ for (const rawLine of ini.split(/\r?\n/)) {
1247
+ const line = rawLine.trim();
1248
+ if (line.startsWith("[")) {
1249
+ current = {};
1250
+ profiles.push(current);
1251
+ continue;
1252
+ }
1253
+ const eq = line.indexOf("=");
1254
+ if (eq === -1 || !current) continue;
1255
+ current[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
1256
+ }
1257
+ return profiles.filter((p) => p.Path && p.Name).sort((a, b) => (Number(b.Default) || 0) - (Number(a.Default) || 0));
1258
+ } catch {
1259
+ return [];
1260
+ }
1261
+ }
1262
+ async function candidateProfileDirs() {
1263
+ const root = zoteroProfileRoot();
1264
+ const dirs = [];
1265
+ for (const p of await parseProfilesIni(root)) {
1266
+ dirs.push(resolve2(root, p.Path.replace(/\//g, "\\")));
1267
+ }
1268
+ try {
1269
+ const profilesDir = join2(root, "Profiles");
1270
+ for (const name2 of await readdir(profilesDir)) {
1271
+ dirs.push(join2(profilesDir, name2));
1272
+ }
1273
+ } catch {
1274
+ }
1275
+ return dirs;
1276
+ }
1277
+ async function readDataDirFrom(dir) {
1278
+ let text;
1279
+ try {
1280
+ text = await readFile2(join2(dir, "prefs.js"), "utf8");
1281
+ } catch {
1282
+ return null;
1283
+ }
1284
+ const m = /user_pref\(\s*["']extensions\.zotero\.dataDir["']\s*,\s*["']([^"']+)["']\s*\)/.exec(text);
1285
+ return m ? unescapePath(m[1]) : null;
1286
+ }
1287
+ async function isValidDataDir(dir) {
1288
+ if (!dir) return false;
1289
+ try {
1290
+ const s = await stat(join2(dir, "zotero.sqlite"));
1291
+ return s.isFile();
1292
+ } catch {
1293
+ return false;
1294
+ }
1295
+ }
1296
+ var cached2 = null;
1297
+ async function resolveDataDir() {
1298
+ if (cached2) return cached2;
1299
+ const result = { dataDir: null, profileDir: null, source: "none" };
1300
+ for (const dir of await candidateProfileDirs()) {
1301
+ const dataDir = await readDataDirFrom(dir);
1302
+ if (dataDir && await isValidDataDir(dataDir)) {
1303
+ result.dataDir = dataDir;
1304
+ result.profileDir = dir;
1305
+ result.source = "prefs";
1306
+ break;
1307
+ }
1308
+ if (dataDir && !result.dataDir) {
1309
+ result.dataDir = dataDir;
1310
+ result.profileDir = dir;
1311
+ result.source = "prefs-unverified";
1312
+ }
1313
+ }
1314
+ if (!result.dataDir) {
1315
+ const fallback = join2(homedir2(), "Zotero");
1316
+ if (await isValidDataDir(fallback)) {
1317
+ result.dataDir = fallback;
1318
+ result.source = "default";
1319
+ }
1320
+ }
1321
+ if (result.source === "prefs-unverified") warn(`dataDir ${result.dataDir} has no zotero.sqlite`);
1322
+ cached2 = result;
1323
+ return result;
1324
+ }
1325
+
1326
+ // src/node/zotero/health.js
1327
+ var CONNECTOR_API_VERSION = "3";
1328
+ function baseUrl(portOverride) {
1329
+ return `http://127.0.0.1:${portOverride ?? 23119}`;
1330
+ }
1331
+ function connectorHeaders(extra = {}) {
1332
+ return { "X-Zotero-Connector-API-Version": CONNECTOR_API_VERSION, "Content-Type": "application/json", ...extra };
1333
+ }
1334
+ async function ping({ port, timeoutMs = 1500 } = {}) {
1335
+ const config = await loadConfig();
1336
+ const url = `${baseUrl(port ?? config.zoteroPort)}/connector/ping`;
1337
+ try {
1338
+ const res = await httpGet(url, { timeoutMs });
1339
+ const version = res.headers.get("x-zotero-version") ?? "";
1340
+ await res.text().catch(() => "");
1341
+ return { running: true, version, url: baseUrl(port ?? config.zoteroPort) };
1342
+ } catch (e) {
1343
+ return { running: false, version: "", url: baseUrl(port ?? config.zoteroPort), error: e.message, code: e.code ?? "network" };
1344
+ }
1345
+ }
1346
+ async function describe() {
1347
+ const config = await loadConfig();
1348
+ const status = await ping();
1349
+ const { dataDir, profileDir, source } = await resolveDataDir();
1350
+ let library = null;
1351
+ if (status.running) {
1352
+ try {
1353
+ const res = await httpGet(`${baseUrl(config.zoteroPort)}/api/users/0/items/top?limit=1&format=json`, {
1354
+ timeoutMs: 3e3,
1355
+ headers: { "Zotero-API-Version": "3" }
1356
+ });
1357
+ const total = res.headers.get("total-results");
1358
+ await res.text().catch(() => "");
1359
+ library = { readable: true, itemCount: total ? Number(total) : null };
1360
+ } catch (e) {
1361
+ library = { readable: false, error: e.message };
1362
+ }
1363
+ }
1364
+ return {
1365
+ running: status.running,
1366
+ version: status.version,
1367
+ endpoint: status.url,
1368
+ dataDir: config.dataDirOverride || dataDir || "",
1369
+ dataDirSource: config.dataDirOverride ? "override" : source,
1370
+ profileDir,
1371
+ library,
1372
+ saveMode: config.saveMode
1373
+ };
1374
+ }
1375
+ async function ensureZotero() {
1376
+ const status = await ping();
1377
+ if (!status.running) {
1378
+ throw Object.assign(new Error("Zotero \u672A\u8FD0\u884C"), { code: "zotero_not_running" });
1379
+ }
1380
+ return status;
1381
+ }
1382
+
1383
+ // src/node/zotero/connector.js
1384
+ async function request(path, { method = "POST", body, headers = {}, timeoutMs = 6e4 } = {}) {
1385
+ await ensureZotero();
1386
+ const config = await loadConfig();
1387
+ const controller = new AbortController();
1388
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1389
+ try {
1390
+ const res = await fetch(`${baseUrl(config.zoteroPort)}${path}`, {
1391
+ method,
1392
+ headers: connectorHeaders(headers),
1393
+ body,
1394
+ signal: controller.signal
1395
+ });
1396
+ return res;
1397
+ } catch (e) {
1398
+ if (e?.name === "AbortError") throw Object.assign(new Error(`Zotero \u8BF7\u6C42\u8D85\u65F6\uFF08${path}\uFF09`), { code: "timeout" });
1399
+ throw Object.assign(new Error(`\u65E0\u6CD5\u8FDE\u63A5 Zotero\uFF08${path}\uFF09\uFF1A${e.message}`), { code: "network" });
1400
+ } finally {
1401
+ clearTimeout(timer);
1402
+ }
1403
+ }
1404
+ async function postJson(path, payload, options) {
1405
+ const res = await request(path, { body: JSON.stringify(payload), ...options });
1406
+ const text = await res.text().catch(() => "");
1407
+ if (res.status === 409) {
1408
+ throw Object.assign(new Error("Zotero \u4F1A\u8BDD\u51B2\u7A81\uFF0C\u8BF7\u91CD\u8BD5"), { code: "session_exists" });
1409
+ }
1410
+ if (res.status >= 400) {
1411
+ let detail = text;
1412
+ try {
1413
+ detail = JSON.parse(text)?.error ?? text;
1414
+ } catch {
1415
+ }
1416
+ throw Object.assign(new Error(`Zotero \u8FD4\u56DE ${res.status}: ${detail}`), { code: "zotero_error", status: res.status });
1417
+ }
1418
+ return { status: res.status, body: text };
1419
+ }
1420
+ async function postBuffer(path, buffer, { metadata, contentType, timeoutMs }) {
1421
+ const headers = {
1422
+ "Content-Type": contentType,
1423
+ "Content-Length": String(buffer.length)
1424
+ };
1425
+ if (metadata) headers["X-Metadata"] = JSON.stringify(metadata);
1426
+ const res = await request(path, { body: buffer, headers, timeoutMs });
1427
+ const text = await res.text().catch(() => "");
1428
+ if (res.status >= 400) {
1429
+ let detail = text;
1430
+ try {
1431
+ detail = JSON.parse(text)?.error ?? text;
1432
+ } catch {
1433
+ }
1434
+ throw Object.assign(new Error(`Zotero \u9644\u4EF6\u5199\u5165\u5931\u8D25 ${res.status}: ${detail}`), { code: "zotero_error", status: res.status });
1435
+ }
1436
+ return { status: res.status, body: text };
1437
+ }
1438
+ async function saveToZotero({ item, pdfBuffer, pdfFileName: pdfFileName2, pdfUrl: pdfUrl2, sessionID = randomUUID(), timeoutMs = 6e4 }) {
1439
+ await postJson("/connector/saveItems", { sessionID, items: [item], uri: pdfUrl2 || item.url || "" }, { timeoutMs: 3e4 });
1440
+ let attachmentSaved = false;
1441
+ if (pdfBuffer && pdfBuffer.length) {
1442
+ const metadata = {
1443
+ sessionID,
1444
+ parentItemID: item.id,
1445
+ title: pdfFileName2 || "Full Text PDF",
1446
+ url: pdfUrl2 || item.url || ""
1447
+ };
1448
+ await postBuffer(`/connector/saveAttachment?sessionID=${encodeURIComponent(sessionID)}`, pdfBuffer, {
1449
+ metadata,
1450
+ contentType: "application/pdf",
1451
+ timeoutMs
1452
+ });
1453
+ attachmentSaved = true;
1454
+ }
1455
+ log(`saved to Zotero: ${item.title} (attachment: ${attachmentSaved})`);
1456
+ return { sessionID, attachmentSaved };
1457
+ }
1458
+ async function getSelectedCollection() {
1459
+ const res = await request("/connector/getSelectedCollection", { body: JSON.stringify({}), timeoutMs: 4e3 });
1460
+ try {
1461
+ return await res.json();
1462
+ } catch {
1463
+ return null;
1464
+ }
1465
+ }
1466
+
1467
+ // src/node/exporter.js
1468
+ import { writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
1469
+ import { join as join3, resolve as resolve3 } from "node:path";
1470
+
1471
+ // src/node/zotero/naming.js
1472
+ var ILLEGAL = /[<>:"\/\\|?*\u0000-\u001f]/g;
1473
+ function safe(part) {
1474
+ return String(part ?? "").replace(ILLEGAL, "").replace(/\s+/g, " ").trim();
1475
+ }
1476
+ function clampPart(part, max) {
1477
+ const s = safe(part);
1478
+ if (s.length <= max) return s;
1479
+ return `${s.slice(0, Math.max(1, max - 1)).trimEnd()}\u2026`;
1480
+ }
1481
+ function firstAuthorLabel(authors2) {
1482
+ const list = authors2 ?? [];
1483
+ if (!list.length) return "Unknown";
1484
+ const first = list[0];
1485
+ const name2 = [first.lastName, first.firstName].filter(Boolean).join(", ");
1486
+ return name2 || "Unknown";
1487
+ }
1488
+ function renderName(record, template = "{author}_{year}_{title}", { maxLength = 180 } = {}) {
1489
+ const tokens = {
1490
+ author: firstAuthorLabel(record.authors),
1491
+ authors: (record.authors ?? []).map((a) => a.lastName).filter(Boolean).slice(0, 3).join("-") || "Unknown",
1492
+ year: record.year ? String(record.year) : "n.d.",
1493
+ title: record.title || "Untitled",
1494
+ journal: record.container || record.publisher || "",
1495
+ doi: (record.doi || "").replace(/[^\w.-]+/g, "_"),
1496
+ arxiv: (record.arxiv || "").replace(/[^\w.-]+/g, "_")
1497
+ };
1498
+ let out = String(template);
1499
+ for (const [key, value] of Object.entries(tokens)) {
1500
+ out = out.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
1501
+ }
1502
+ if (!out.trim()) out = "{author}_{year}_{title}";
1503
+ if (!out.trim()) out = "Untitled";
1504
+ return clampPart(out, maxLength);
1505
+ }
1506
+ function pdfFileName(record, template) {
1507
+ return `${renderName(record, template)}.pdf`;
1508
+ }
1509
+
1510
+ // src/node/exporter.js
1511
+ function cslJson(record) {
1512
+ const authors2 = (record.authors ?? []).map((a) => ({
1513
+ family: a.lastName ?? "",
1514
+ given: a.firstName ?? ""
1515
+ }));
1516
+ const type = {
1517
+ journalArticle: "article-journal",
1518
+ book: "book",
1519
+ bookSection: "chapter",
1520
+ conferencePaper: "paper-conference",
1521
+ preprint: "manuscript",
1522
+ thesis: "thesis",
1523
+ report: "report",
1524
+ dataset: "dataset"
1525
+ }[record.itemType] ?? "article-journal";
1526
+ return [
1527
+ {
1528
+ id: record.doi || record.arxiv || record.title || "item",
1529
+ type,
1530
+ title: record.title ?? "",
1531
+ author: authors2,
1532
+ issued: record.year ? { "date-parts": [[record.year]] } : void 0,
1533
+ "container-title": record.container || void 0,
1534
+ publisher: record.publisher || void 0,
1535
+ volume: record.volume || void 0,
1536
+ issue: record.issue || void 0,
1537
+ page: record.pages || void 0,
1538
+ DOI: record.doi || void 0,
1539
+ ISBN: record.isbn || void 0,
1540
+ ISSN: record.issn || void 0,
1541
+ abstract: record.abstract || void 0,
1542
+ URL: record.url || void 0,
1543
+ note: record.arxiv ? `arXiv:${record.arxiv}` : void 0
1544
+ }
1545
+ ];
1546
+ }
1547
+ function risLine(tag2, value) {
1548
+ return value ? `${tag2} - ${String(value).replace(/[\r\n]+/g, " ")}` : null;
1549
+ }
1550
+ function ris(record) {
1551
+ const typeMap = {
1552
+ journalArticle: "JOUR",
1553
+ book: "BOOK",
1554
+ bookSection: "CHAP",
1555
+ conferencePaper: "CONF",
1556
+ preprint: "EJOUR",
1557
+ thesis: "THES",
1558
+ report: "RPRT",
1559
+ dataset: "DATA"
1560
+ };
1561
+ const lines = [risLine("TY", typeMap[record.itemType] ?? "JOUR")];
1562
+ for (const a of record.authors ?? []) {
1563
+ lines.push(risLine("AU", [a.lastName, a.firstName].filter(Boolean).join(", ")));
1564
+ }
1565
+ lines.push(
1566
+ risLine("TI", record.title),
1567
+ risLine("JO", record.container),
1568
+ risLine("PB", record.publisher),
1569
+ risLine("VL", record.volume),
1570
+ risLine("IS", record.issue),
1571
+ risLine("SP", record.pages),
1572
+ risLine("PY", record.year ? String(record.year) : ""),
1573
+ risLine("AB", record.abstract),
1574
+ risLine("DO", record.doi),
1575
+ risLine("SN", record.isbn || record.issn),
1576
+ risLine("UR", record.url),
1577
+ risLine("ER", "")
1578
+ );
1579
+ return lines.filter(Boolean).join("\r\n") + "\r\n";
1580
+ }
1581
+ async function exportToDirectory(record, pdfBuffer) {
1582
+ const config = await loadConfig();
1583
+ const dir = resolve3(config.dirPath || "");
1584
+ if (!dir) throw Object.assign(new Error("\u672A\u914D\u7F6E\u5BFC\u51FA\u76EE\u5F55"), { code: "no_dir" });
1585
+ await mkdir2(dir, { recursive: true });
1586
+ const base = pdfFileName(record, config.naming);
1587
+ const stem = base.replace(/\.pdf$/i, "");
1588
+ const pdfPath = join3(dir, base);
1589
+ const jsonPath = join3(dir, `${stem}.csl.json`);
1590
+ const risPath = join3(dir, `${stem}.ris`);
1591
+ const safePaths = [pdfPath, jsonPath, risPath].map((p) => resolve3(dir, p.replace(/^.*[\\/]/, "")));
1592
+ if (pdfBuffer?.length) await writeFile2(safePaths[0], pdfBuffer);
1593
+ await writeFile2(safePaths[1], JSON.stringify(cslJson(record), null, 2), "utf8");
1594
+ await writeFile2(safePaths[2], ris(record), "utf8");
1595
+ return { dir, pdfPath: safePaths[0], jsonPath: safePaths[1], risPath: safePaths[2] };
1596
+ }
1597
+
1598
+ // src/node/zotero/local-api.js
1599
+ function apiHeaders() {
1600
+ return { "Zotero-API-Version": "3" };
1601
+ }
1602
+ async function call(path, { timeoutMs = 1e4, headers = {} } = {}) {
1603
+ await ensureZotero();
1604
+ const config = await loadConfig();
1605
+ return httpGetJson(`${baseUrl(config.zoteroPort)}${path}`, { timeoutMs, headers: { ...apiHeaders(), ...headers } });
1606
+ }
1607
+ function unwrap(entry) {
1608
+ return entry?.data ?? entry;
1609
+ }
1610
+ async function searchItems(query, { limit = 25, qmode = "everything" } = {}) {
1611
+ const params = new URLSearchParams({ format: "json", qmode });
1612
+ if (query) params.set("q", query);
1613
+ if (limit) params.set("limit", String(limit));
1614
+ const body = await call(`/api/users/0/items?${params}`);
1615
+ return Array.isArray(body) ? body.map(unwrap) : [unwrap(body)];
1616
+ }
1617
+ async function listCollections() {
1618
+ const body = await call("/api/users/0/collections?format=json");
1619
+ return Array.isArray(body) ? body.map(unwrap) : [];
1620
+ }
1621
+ async function getFileBuffer(key) {
1622
+ await ensureZotero();
1623
+ const config = await loadConfig();
1624
+ return httpGetBuffer(`${baseUrl(config.zoteroPort)}/api/users/0/items/${encodeURIComponent(key)}/file`, { timeoutMs: 6e4 });
1625
+ }
1626
+ async function findDuplicates({ doi, arxiv, title }) {
1627
+ const queries = [];
1628
+ if (doi) queries.push({ field: "DOI", value: String(doi).toLowerCase() });
1629
+ if (arxiv) queries.push({ field: "extra", value: String(arxiv).toLowerCase() });
1630
+ const hits = [];
1631
+ const seen = /* @__PURE__ */ new Set();
1632
+ for (const q of queries) {
1633
+ try {
1634
+ const items = await searchItems(q.value, { limit: 25 });
1635
+ for (const item of items) {
1636
+ const itemDoi = String(item.DOI ?? "").toLowerCase();
1637
+ const extra = String(item.extra ?? "").toLowerCase();
1638
+ const matched = q.field === "DOI" ? itemDoi === q.value : extra.includes(q.value);
1639
+ if (!matched) continue;
1640
+ if (seen.has(item.key)) continue;
1641
+ seen.add(item.key);
1642
+ hits.push(item);
1643
+ }
1644
+ } catch (e) {
1645
+ warn(`duplicate search failed for ${q.field}:`, e.message);
1646
+ }
1647
+ }
1648
+ if (!hits.length && title) {
1649
+ try {
1650
+ const items = await searchItems(title.slice(0, 80), { limit: 10, qmode: "titleCreatorYear" });
1651
+ for (const item of items) {
1652
+ const a = String(item.title ?? "").toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, " ");
1653
+ const b = String(title).toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, " ");
1654
+ if (!a || !b) continue;
1655
+ if (a.includes(b.slice(0, 40)) || b.includes(a.slice(0, 40))) {
1656
+ if (seen.has(item.key)) continue;
1657
+ seen.add(item.key);
1658
+ hits.push(item);
1659
+ }
1660
+ }
1661
+ } catch (e) {
1662
+ warn("title duplicate search failed:", e.message);
1663
+ }
1664
+ }
1665
+ return hits;
1666
+ }
1667
+
1668
+ // src/node/pipeline.js
1669
+ var FAILURE_MESSAGES = {
1670
+ no_source: "\u6CA1\u6709\u627E\u5230\u5F00\u653E\u83B7\u53D6\u7684\u5168\u6587\u6765\u6E90",
1671
+ paywalled: "\u8BE5\u6587\u732E\u6CA1\u6709\u5F00\u653E\u83B7\u53D6\u5168\u6587\uFF08\u53EF\u80FD\u662F\u4ED8\u8D39\u5899\uFF09",
1672
+ not_found: "\u6807\u8BC6\u7B26\u5728\u5143\u6570\u636E\u670D\u52A1\u4E2D\u67E5\u4E0D\u5230",
1673
+ timeout: "\u8BF7\u6C42\u8D85\u65F6",
1674
+ network: "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25",
1675
+ zotero_not_running: "Zotero \u672A\u8FD0\u884C",
1676
+ zotero_error: "Zotero \u8FD4\u56DE\u9519\u8BEF",
1677
+ no_dir: "\u672A\u914D\u7F6E\u5BFC\u51FA\u76EE\u5F55",
1678
+ no_metadata: "\u65E0\u6CD5\u89E3\u6790\u51FA\u5143\u6570\u636E"
1679
+ };
1680
+ function failure(code, message, extra = {}) {
1681
+ return {
1682
+ code,
1683
+ message: message || FAILURE_MESSAGES[code] || "\u64CD\u4F5C\u5931\u8D25",
1684
+ // Only transient conditions are worth a retry button.
1685
+ retryable: !["paywalled", "no_source", "not_found", "no_dir", "no_metadata"].includes(code),
1686
+ ...extra
1687
+ };
1688
+ }
1689
+ function pdfPathFor(key) {
1690
+ return join4(PDF_DIR, `${key.replace(/[^\w.-]+/g, "_")}.pdf`);
1691
+ }
1692
+ async function update(key, patch) {
1693
+ const next = await patchItem(key, patch);
1694
+ if (next) emitItem(next);
1695
+ return next;
1696
+ }
1697
+ async function startTask(key, kind) {
1698
+ const task = await putTask({
1699
+ id: randomUUID2(),
1700
+ key,
1701
+ kind,
1702
+ state: "running",
1703
+ progress: 0,
1704
+ message: "",
1705
+ createdAt: Date.now(),
1706
+ updatedAt: Date.now()
1707
+ });
1708
+ emitTask(task);
1709
+ return task;
1710
+ }
1711
+ async function finishTask(task, state2, message) {
1712
+ const next = await putTask({ id: task.id, state: state2, progress: 100, message });
1713
+ emitTask(next);
1714
+ return next;
1715
+ }
1716
+ async function scanText(text) {
1717
+ const found = extractIdentifiers(text);
1718
+ const existing = await listItems();
1719
+ const created = [];
1720
+ for (const hit of found) {
1721
+ const provisional = buildItem({
1722
+ doi: hit.kind === "doi" ? hit.value : "",
1723
+ arxiv: hit.kind === "arxiv" ? hit.value : "",
1724
+ isbn: hit.kind === "isbn" ? hit.value : "",
1725
+ pmid: hit.kind === "pmid" ? hit.value : "",
1726
+ title: hit.kind === "title" ? hit.value : ""
1727
+ });
1728
+ if (!provisional.key) continue;
1729
+ const clash = existing.find((e) => sameWork(e, provisional)) ?? await getItem(provisional.key);
1730
+ if (clash) continue;
1731
+ const item = await putItem({
1732
+ ...provisional,
1733
+ kind: hit.kind,
1734
+ rawValue: hit.value,
1735
+ display: hit.display || hit.value,
1736
+ confidence: hit.confidence,
1737
+ state: "discovered",
1738
+ createdAt: Date.now()
1739
+ });
1740
+ existing.push(item);
1741
+ created.push(item);
1742
+ emitItem(item);
1743
+ }
1744
+ return created;
1745
+ }
1746
+ async function resolveItem(key) {
1747
+ const item = await getItem(key);
1748
+ if (!item) throw failure("not_found", "\u6761\u76EE\u4E0D\u5B58\u5728");
1749
+ if (item.record && item.state !== "resolve_failed") return item;
1750
+ const task = await startTask(key, "resolve");
1751
+ await update(key, { state: "resolving", error: null });
1752
+ const config = await loadConfig();
1753
+ try {
1754
+ const record = await withRetry(
1755
+ () => resolveIdentifier(
1756
+ { kind: item.kind, value: item.rawValue || item.doi || item.arxiv || item.isbn || item.pmid || item.title },
1757
+ { timeoutMs: 2e4, unpaywallEmail: config.unpaywallEmail }
1758
+ ),
1759
+ { ...config.retry, label: `resolve ${key}` }
1760
+ );
1761
+ if (!record) {
1762
+ await update(key, { state: "resolve_failed", error: failure("no_metadata") });
1763
+ await finishTask(task, "failed", "\u5143\u6570\u636E\u89E3\u6790\u5931\u8D25");
1764
+ return getItem(key);
1765
+ }
1766
+ const merged = buildItem({ ...item, ...record });
1767
+ const updated = await patchItem(key, {
1768
+ ...merged,
1769
+ key,
1770
+ state: "resolved",
1771
+ record,
1772
+ error: null,
1773
+ updatedAt: Date.now()
1774
+ });
1775
+ emitItem(updated);
1776
+ await finishTask(task, "done", "\u5143\u6570\u636E\u89E3\u6790\u5B8C\u6210");
1777
+ return updated;
1778
+ } catch (e) {
1779
+ const err = failure(e.code ?? "network", e.message);
1780
+ await update(key, { state: "resolve_failed", error: err });
1781
+ await finishTask(task, "failed", err.message);
1782
+ return getItem(key);
1783
+ }
1784
+ }
1785
+ async function fetchItemPdf(key) {
1786
+ const item = await getItem(key);
1787
+ if (!item) throw failure("not_found", "\u6761\u76EE\u4E0D\u5B58\u5728");
1788
+ if (item.pdf?.path) return item;
1789
+ if (!item.record) {
1790
+ const resolved = await resolveItem(key);
1791
+ if (!resolved?.record) throw failure("no_metadata");
1792
+ }
1793
+ const current = await getItem(key);
1794
+ const task = await startTask(key, "fetch");
1795
+ await update(key, { state: "fetching", error: null });
1796
+ const config = await loadConfig();
1797
+ try {
1798
+ const result = await withRetry(
1799
+ (attempt) => {
1800
+ if (attempt > 1) emitTask({ id: task.id, state: "running", progress: 10, message: `\u7B2C ${attempt} \u6B21\u5C1D\u8BD5`, updatedAt: Date.now() });
1801
+ return fetchPdf(current.record, { timeoutMs: config.fetchTimeoutMs, unpaywallEmail: config.unpaywallEmail });
1802
+ },
1803
+ { ...config.retry, label: `fetch ${key}` }
1804
+ );
1805
+ const path = pdfPathFor(key);
1806
+ await mkdir3(dirname2(path), { recursive: true });
1807
+ await writeFile3(path, result.buffer);
1808
+ const updated = await patchItem(key, {
1809
+ state: "fetched",
1810
+ pdf: { path, size: result.buffer.length, source: result.source, url: result.url },
1811
+ error: null,
1812
+ updatedAt: Date.now()
1813
+ });
1814
+ emitItem(updated);
1815
+ await finishTask(task, "done", `\u5168\u6587\u4E0B\u8F7D\u5B8C\u6210\uFF08${(result.buffer.length / 1024).toFixed(0)} KB\uFF09`);
1816
+ return updated;
1817
+ } catch (e) {
1818
+ const err = failure(e.code ?? "network", e.message, { detail: e.detail });
1819
+ await update(key, { state: "fetch_failed", error: err });
1820
+ await finishTask(task, "failed", err.message);
1821
+ return getItem(key);
1822
+ }
1823
+ }
1824
+ async function previewConflict(key) {
1825
+ const item = await getItem(key);
1826
+ if (!item) return null;
1827
+ const record = item.record;
1828
+ if (!record) return null;
1829
+ const status = await ping();
1830
+ if (!status.running) return null;
1831
+ const hits = await findDuplicates({ doi: record.doi, arxiv: record.arxiv, title: record.title });
1832
+ if (!hits.length) return null;
1833
+ const existing = hits[0];
1834
+ const incoming = toZoteroItem(record, { clientId: "preview" });
1835
+ const fields = ["title", "DOI", "publicationTitle", "volume", "issue", "pages", "date", "publisher", "abstractNote"];
1836
+ const diff = [];
1837
+ for (const f of fields) {
1838
+ const before = String(existing[f] ?? "");
1839
+ const after = String(incoming[f] ?? "");
1840
+ if (before === after) continue;
1841
+ diff.push({ field: f, before, after });
1842
+ }
1843
+ const beforeAuthors = (existing.creators ?? []).map((c) => `${c.lastName ?? ""}, ${c.firstName ?? ""}`.trim()).join("; ");
1844
+ const afterAuthors = (incoming.creators ?? []).map((c) => `${c.lastName ?? ""}, ${c.firstName ?? ""}`.trim()).join("; ");
1845
+ if (beforeAuthors !== afterAuthors) diff.push({ field: "creators", before: beforeAuthors, after: afterAuthors });
1846
+ return { key: existing.key, existing, incoming, diff, candidates: hits };
1847
+ }
1848
+ async function saveItem(key, { mode, tags } = {}) {
1849
+ const item = await getItem(key);
1850
+ if (!item) throw failure("not_found", "\u6761\u76EE\u4E0D\u5B58\u5728");
1851
+ if (!item.record) {
1852
+ const resolved = await resolveItem(key);
1853
+ if (!resolved?.record) throw failure("no_metadata");
1854
+ }
1855
+ const current = await getItem(key);
1856
+ const task = await startTask(key, "save");
1857
+ await update(key, { state: "saving", error: null });
1858
+ const config = await loadConfig();
1859
+ const wantMode = mode || config.saveMode;
1860
+ try {
1861
+ let result;
1862
+ if (wantMode === "dir") {
1863
+ let buffer = null;
1864
+ if (current.pdf?.path) {
1865
+ const { readFile: readFile4 } = await import("node:fs/promises");
1866
+ buffer = await readFile4(current.pdf.path).catch(() => null);
1867
+ }
1868
+ result = await exportToDirectory(current.record, buffer);
1869
+ const updated2 = await patchItem(key, { state: "saved", saveMode: "dir", export: result, error: null, updatedAt: Date.now() });
1870
+ emitItem(updated2);
1871
+ await finishTask(task, "done", "\u5DF2\u5BFC\u51FA\u5230\u76EE\u5F55");
1872
+ return updated2;
1873
+ }
1874
+ let pdfBuffer = null;
1875
+ if (current.pdf?.path) {
1876
+ const { readFile: readFile4 } = await import("node:fs/promises");
1877
+ pdfBuffer = await readFile4(current.pdf.path).catch(() => null);
1878
+ }
1879
+ if (!pdfBuffer) {
1880
+ const fetched = await fetchItemPdf(key);
1881
+ if (fetched?.pdf?.path) {
1882
+ const { readFile: readFile4 } = await import("node:fs/promises");
1883
+ pdfBuffer = await readFile4(fetched.pdf.path).catch(() => null);
1884
+ }
1885
+ }
1886
+ const clientId = `dshz_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1887
+ const zoteroItem = toZoteroItem(current.record, {
1888
+ clientId,
1889
+ tags: tags?.length ? tags : config.preferredTags
1890
+ });
1891
+ const saved = await saveToZotero({
1892
+ item: zoteroItem,
1893
+ pdfBuffer,
1894
+ pdfFileName: pdfFileName(current.record, config.naming),
1895
+ pdfUrl: current.pdf?.url || current.record.url || ""
1896
+ });
1897
+ let zoteroKey = null;
1898
+ try {
1899
+ const hits = await findDuplicates({ doi: current.record.doi, arxiv: current.record.arxiv, title: current.record.title });
1900
+ zoteroKey = hits[0]?.key ?? null;
1901
+ } catch (e) {
1902
+ warn("post-save lookup failed:", e.message);
1903
+ }
1904
+ const updated = await patchItem(key, {
1905
+ state: "saved",
1906
+ saveMode: "zotero",
1907
+ zotero: { key: zoteroKey, sessionID: saved.sessionID, attachmentSaved: saved.attachmentSaved },
1908
+ error: null,
1909
+ updatedAt: Date.now()
1910
+ });
1911
+ emitItem(updated);
1912
+ await finishTask(task, "done", "\u5DF2\u4FDD\u5B58\u5230 Zotero");
1913
+ return updated;
1914
+ } catch (e) {
1915
+ const err = failure(e.code ?? "network", e.message, { detail: e.detail });
1916
+ await update(key, { state: "save_failed", error: err });
1917
+ await finishTask(task, "failed", err.message);
1918
+ return getItem(key);
1919
+ }
1920
+ }
1921
+ async function discardItem(key) {
1922
+ const item = await getItem(key);
1923
+ if (item?.pdf?.path) {
1924
+ await unlink(item.pdf.path).catch(() => {
1925
+ });
1926
+ }
1927
+ await removeItem(key);
1928
+ emit("removed", { key });
1929
+ }
1930
+ async function retryItem(key) {
1931
+ const item = await getItem(key);
1932
+ if (!item) return null;
1933
+ if (item.state === "resolve_failed") return resolveItem(key);
1934
+ if (item.state === "fetch_failed") return fetchItemPdf(key);
1935
+ if (item.state === "save_failed") return saveItem(key);
1936
+ return item;
1937
+ }
1938
+
1939
+ // src/node/routes.js
1940
+ var PREFIX2 = "/api/dsh-literature";
1941
+ function pathOf(req) {
1942
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
1943
+ return u.pathname;
1944
+ }
1945
+ function methodOk(req, ...allowed) {
1946
+ return allowed.includes(req.method);
1947
+ }
1948
+ function safePdfPath(key) {
1949
+ const base = resolve4(PDF_DIR);
1950
+ const candidate = resolve4(base, `${String(key).replace(/[^\w.-]+/g, "_")}.pdf`);
1951
+ if (!candidate.startsWith(base)) return null;
1952
+ return candidate;
1953
+ }
1954
+ async function handleState(res) {
1955
+ const config = await loadConfig();
1956
+ const zotero = await describe();
1957
+ const items = await listItems();
1958
+ const tasks = await listTasks().then((all) => all.filter((t) => t.state === "running"));
1959
+ let selectedCollection = null;
1960
+ if (zotero.running) {
1961
+ selectedCollection = await getSelectedCollection().catch(() => null);
1962
+ }
1963
+ writeJson(res, 200, { config, zotero, items, tasks, selectedCollection });
1964
+ }
1965
+ async function handleEvents(req, res) {
1966
+ writeSseHead(res);
1967
+ const remove = addClient(res);
1968
+ emit("hello", { ok: true, clients: clientCount() });
1969
+ try {
1970
+ await responseClosed(res);
1971
+ } catch {
1972
+ } finally {
1973
+ remove();
1974
+ }
1975
+ }
1976
+ async function servePdf(req, res, key) {
1977
+ const path = safePdfPath(key);
1978
+ if (!path) {
1979
+ writeJson(res, 400, { error: "invalid key" });
1980
+ return;
1981
+ }
1982
+ let buffer;
1983
+ try {
1984
+ buffer = await readFile3(path);
1985
+ } catch {
1986
+ writeJson(res, 404, { error: "pdf not downloaded yet" });
1987
+ return;
1988
+ }
1989
+ const range = parseRange(req.headers.range, buffer.length);
1990
+ if (range?.invalid) {
1991
+ res.writeHead(416, { "content-range": `bytes */${buffer.length}`, "content-length": 0 });
1992
+ res.end();
1993
+ return;
1994
+ }
1995
+ if (range) {
1996
+ const slice = buffer.subarray(range.start, range.end + 1);
1997
+ res.writeHead(206, {
1998
+ "content-type": "application/pdf",
1999
+ "content-length": slice.length,
2000
+ "content-range": `bytes ${range.start}-${range.end}/${buffer.length}`,
2001
+ "accept-ranges": "bytes"
2002
+ });
2003
+ res.end(range ? slice : buffer);
2004
+ return;
2005
+ }
2006
+ res.writeHead(200, {
2007
+ "content-type": "application/pdf",
2008
+ "content-length": buffer.length,
2009
+ "accept-ranges": "bytes",
2010
+ "cache-control": "private, max-age=300"
2011
+ });
2012
+ res.end(buffer);
2013
+ }
2014
+ async function serveZoteroPdf(res, key) {
2015
+ try {
2016
+ const { buffer } = await getFileBuffer(key);
2017
+ res.writeHead(200, {
2018
+ "content-type": "application/pdf",
2019
+ "content-length": buffer.length,
2020
+ "accept-ranges": "bytes"
2021
+ });
2022
+ res.end(buffer);
2023
+ } catch (e) {
2024
+ writeJson(res, 502, { error: e.message });
2025
+ }
2026
+ }
2027
+ async function handleAnnotations(req, res, key) {
2028
+ if (req.method === "GET") {
2029
+ writeJson(res, 200, { annotations: await getAnnotations(key) });
2030
+ return;
2031
+ }
2032
+ const body = await readJsonBody2(req);
2033
+ if (req.method === "POST") {
2034
+ const created = await addAnnotation(key, body ?? {});
2035
+ writeJson(res, 201, { annotation: created });
2036
+ return;
2037
+ }
2038
+ if (req.method === "PATCH") {
2039
+ const updated = await patchAnnotation(key, body?.id, body?.patch ?? {});
2040
+ writeJson(res, 200, { annotation: updated });
2041
+ return;
2042
+ }
2043
+ if (req.method === "DELETE") {
2044
+ const id = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("id");
2045
+ await removeAnnotation(key, id);
2046
+ writeJson(res, 200, { ok: true });
2047
+ return;
2048
+ }
2049
+ writeJson(res, 405, { error: "method not allowed" });
2050
+ }
2051
+ async function readJsonBody2(req) {
2052
+ const { readJsonBody: read } = await Promise.resolve().then(() => (init_http(), http_exports));
2053
+ return read(req);
2054
+ }
2055
+ async function handler(req, res) {
2056
+ if (!isLoopbackRequest(req)) {
2057
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2058
+ return;
2059
+ }
2060
+ const path = pathOf(req);
2061
+ if (!path.startsWith(PREFIX2)) {
2062
+ writeJson(res, 404, { error: "not found" });
2063
+ return;
2064
+ }
2065
+ const rest = path.slice(PREFIX2.length).replace(/^\/+/, "");
2066
+ const parts = rest.split("/").filter(Boolean);
2067
+ const head = parts[0] ?? "";
2068
+ try {
2069
+ if (head === "events" && methodOk(req, "GET")) {
2070
+ await handleEvents(req, res);
2071
+ return;
2072
+ }
2073
+ if (head === "state" && methodOk(req, "GET")) {
2074
+ await handleState(res);
2075
+ return;
2076
+ }
2077
+ if (head === "config" && methodOk(req, "GET", "POST")) {
2078
+ if (req.method === "POST") {
2079
+ const patch = await readJsonBody2(req);
2080
+ const next = await saveConfig(patch ?? {});
2081
+ writeJson(res, 200, { config: next });
2082
+ } else {
2083
+ writeJson(res, 200, { config: await loadConfig() });
2084
+ }
2085
+ return;
2086
+ }
2087
+ if (head === "scan" && methodOk(req, "POST")) {
2088
+ const body = await readJsonBody2(req);
2089
+ const created = await scanText(String(body?.text ?? ""));
2090
+ writeJson(res, 200, { created });
2091
+ return;
2092
+ }
2093
+ if (head === "resolve" && methodOk(req, "POST")) {
2094
+ const body = await readJsonBody2(req);
2095
+ const item = await resolveItem(String(body?.key ?? ""));
2096
+ writeJson(res, 200, { item });
2097
+ return;
2098
+ }
2099
+ if (head === "fetch" && methodOk(req, "POST")) {
2100
+ const body = await readJsonBody2(req);
2101
+ const item = await fetchItemPdf(String(body?.key ?? ""));
2102
+ writeJson(res, 200, { item });
2103
+ return;
2104
+ }
2105
+ if (head === "retry" && methodOk(req, "POST")) {
2106
+ const body = await readJsonBody2(req);
2107
+ const item = await retryItem(String(body?.key ?? ""));
2108
+ writeJson(res, 200, { item });
2109
+ return;
2110
+ }
2111
+ if (head === "save" && methodOk(req, "POST")) {
2112
+ const body = await readJsonBody2(req);
2113
+ const item = await saveItem(String(body?.key ?? ""), { mode: body?.mode, tags: body?.tags });
2114
+ writeJson(res, 200, { item });
2115
+ return;
2116
+ }
2117
+ if (head === "diff" && methodOk(req, "POST")) {
2118
+ const body = await readJsonBody2(req);
2119
+ const conflict = await previewConflict(String(body?.key ?? ""));
2120
+ writeJson(res, 200, { conflict });
2121
+ return;
2122
+ }
2123
+ if (head === "discard" && methodOk(req, "POST")) {
2124
+ const body = await readJsonBody2(req);
2125
+ await discardItem(String(body?.key ?? ""));
2126
+ writeJson(res, 200, { ok: true });
2127
+ return;
2128
+ }
2129
+ if (head === "pdf" && parts[1] && methodOk(req, "GET", "HEAD")) {
2130
+ await servePdf(req, res, parts[1]);
2131
+ return;
2132
+ }
2133
+ if (head === "zotero") {
2134
+ const sub = parts[1] ?? "";
2135
+ if (sub === "collections" && methodOk(req, "GET")) {
2136
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
2137
+ const items = await listCollections();
2138
+ writeJson(res, 200, { collections: items, selected: url.searchParams.get("selected") === "1" ? await getSelectedCollection().catch(() => null) : null });
2139
+ return;
2140
+ }
2141
+ if (sub === "items" && methodOk(req, "GET")) {
2142
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
2143
+ const q = url.searchParams.get("q") ?? "";
2144
+ const items = await searchItems(q, { limit: Number(url.searchParams.get("limit") ?? 25) });
2145
+ writeJson(res, 200, { items });
2146
+ return;
2147
+ }
2148
+ if (sub === "file" && parts[2] && methodOk(req, "GET")) {
2149
+ await serveZoteroPdf(res, parts[2]);
2150
+ return;
2151
+ }
2152
+ if (sub === "ping" && methodOk(req, "GET")) {
2153
+ writeJson(res, 200, { status: await ping() });
2154
+ return;
2155
+ }
2156
+ }
2157
+ if (head === "annotations" && parts[1] && methodOk(req, "GET", "POST", "PATCH", "DELETE")) {
2158
+ await handleAnnotations(req, res, parts[1]);
2159
+ return;
2160
+ }
2161
+ writeJson(res, 404, { error: `unknown route: ${path}` });
2162
+ } catch (e) {
2163
+ warn(`route ${path} failed:`, e?.stack ?? e);
2164
+ if (!res.headersSent) writeJson(res, 500, { error: e?.message ?? "internal error" });
2165
+ else res.end();
2166
+ }
2167
+ }
2168
+ function registerRoutes(ctx) {
2169
+ const disposers = [ctx.webServer.register({ kind: "prefix", path: PREFIX2, handler })];
2170
+ log(`routes mounted at ${PREFIX2}/`);
2171
+ return disposers;
2172
+ }
2173
+
2174
+ // src/node/tools.js
2175
+ function textResult(value) {
2176
+ return {
2177
+ schema: { type: "string" },
2178
+ render: (_args, v) => [{ type: "text", text: String(v) }]
2179
+ };
2180
+ }
2181
+ function registerTools(ctx) {
2182
+ const disposers = [];
2183
+ disposers.push(
2184
+ ctx.tools.register({
2185
+ name: "zotero_lookup",
2186
+ description: "\u4ECE\u6587\u672C\u6216\u6807\u8BC6\u7B26\u4E2D\u8BC6\u522B\u5B66\u672F\u6587\u732E\uFF08DOI\u3001arXiv ID\u3001PMID\u3001ISBN\u3001\u6807\u9898\uFF09\uFF0C\u89E3\u6790\u5143\u6570\u636E\u5E76\u751F\u6210\u4FA7\u7A97\u6761\u76EE\u3002\u5F53\u4F60\u68C0\u7D22\u6216\u5F15\u7528\u6587\u732E\u5E76\u5E0C\u671B\u7528\u6237\u80FD\u5728\u4FA7\u8FB9\u680F\u9884\u89C8/\u4FDD\u5B58\u5168\u6587\u65F6\u8C03\u7528\u3002",
2187
+ parameters: {
2188
+ type: "object",
2189
+ properties: {
2190
+ text: { type: "string", description: "\u5305\u542B\u6587\u732E\u6807\u8BC6\u7B26\u6216\u5F15\u7528\u6587\u672C\u7684\u7247\u6BB5" },
2191
+ resolve: { type: "string", description: "\u662F\u5426\u7ACB\u5373\u8054\u7F51\u89E3\u6790\u5143\u6570\u636E\uFF0C\u9ED8\u8BA4 yes" }
2192
+ },
2193
+ required: []
2194
+ },
2195
+ output: textResult(),
2196
+ timeoutMs: 12e4,
2197
+ async execute(args) {
2198
+ const text = String(args?.text ?? "");
2199
+ if (!text.trim()) return "\u6CA1\u6709\u63D0\u4F9B\u6587\u672C\u3002";
2200
+ const created = await scanText(text);
2201
+ if (!created.length) return "\u672A\u8BC6\u522B\u5230\u65B0\u7684\u6587\u732E\u6761\u76EE\uFF08\u53EF\u80FD\u5DF2\u5B58\u5728\u4E8E\u4FA7\u7A97\uFF09\u3002";
2202
+ const wantResolve = String(args?.resolve ?? "yes").toLowerCase() !== "no";
2203
+ if (wantResolve) {
2204
+ await Promise.all(created.map((i) => resolveItem(i.key).catch((e) => warn("resolve failed", e.message))));
2205
+ }
2206
+ const items = await Promise.all(created.map((i) => getItem(i.key)));
2207
+ const lines = items.filter(Boolean).map((i) => `- ${i.record?.title || i.display} [${i.key}]`);
2208
+ return `\u5DF2\u52A0\u5165\u4FA7\u7A97 ${items.length} \u6761\uFF1A
2209
+ ${lines.join("\n")}`;
2210
+ }
2211
+ })
2212
+ );
2213
+ disposers.push(
2214
+ ctx.tools.register({
2215
+ name: "zotero_save",
2216
+ description: "\u628A\u4FA7\u7A97\u4E2D\u5DF2\u8BC6\u522B\u7684\u6587\u732E\u4FDD\u5B58\u5230\u672C\u5730 Zotero \u5E93\u6216\u5BFC\u51FA\u76EE\u5F55\uFF08\u4F1A\u81EA\u52A8\u4E0B\u8F7D\u5168\u6587 PDF\uFF09\u3002",
2217
+ parameters: {
2218
+ type: "object",
2219
+ properties: {
2220
+ key: { type: "string", description: "\u6761\u76EE key\uFF08\u6765\u81EA zotero_lookup \u7684\u8FD4\u56DE\uFF09" },
2221
+ mode: { type: "string", description: "\u4FDD\u5B58\u65B9\u5F0F\uFF1Azotero \u6216 dir\uFF0C\u7F3A\u7701\u7528\u914D\u7F6E\u503C" },
2222
+ tags: { type: "string", description: "\u9017\u53F7\u5206\u9694\u7684\u6807\u7B7E" }
2223
+ },
2224
+ required: ["key"]
2225
+ },
2226
+ output: textResult(),
2227
+ timeoutMs: 3e5,
2228
+ async execute(args) {
2229
+ const key = String(args?.key ?? "");
2230
+ if (!key) return "\u7F3A\u5C11\u6761\u76EE key\u3002";
2231
+ const tags = String(args?.tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
2232
+ const item = await saveItem(key, { mode: args?.mode, tags });
2233
+ if (!item) return `\u627E\u4E0D\u5230\u6761\u76EE ${key}\u3002`;
2234
+ if (item.state === "saved") {
2235
+ return `\u5DF2\u4FDD\u5B58\uFF1A${shortLabel(item.record ?? {})}\uFF08${item.saveMode === "dir" ? item.export?.pdfPath : "Zotero \u5E93"}\uFF09`;
2236
+ }
2237
+ return `\u4FDD\u5B58\u5931\u8D25\uFF1A${item.error?.message ?? "\u672A\u77E5\u9519\u8BEF"}\uFF08${item.error?.code ?? "unknown"}\uFF09`;
2238
+ }
2239
+ })
2240
+ );
2241
+ log("tools registered: zotero_lookup, zotero_save");
2242
+ return disposers;
2243
+ }
2244
+
2245
+ // src/node/session-hook.js
2246
+ function blockText(block) {
2247
+ if (block == null) return "";
2248
+ if (typeof block === "string") return block;
2249
+ if (typeof block.text === "string") return block.text;
2250
+ if (typeof block.content === "string") return block.content;
2251
+ if (Array.isArray(block.content)) return block.content.map(blockText).join("\n");
2252
+ return "";
2253
+ }
2254
+ function messageText(message) {
2255
+ if (!message) return "";
2256
+ if (typeof message === "string") return message;
2257
+ if (Array.isArray(message)) return message.map(blockText).join("\n");
2258
+ if (Array.isArray(message.content)) return message.content.map(blockText).join("\n");
2259
+ if (typeof message.content === "string") return message.content;
2260
+ if (typeof message.text === "string") return message.text;
2261
+ return "";
2262
+ }
2263
+ function textFromSessionEvent(event) {
2264
+ if (!event || typeof event !== "object") return "";
2265
+ const type = event.type;
2266
+ const data = event.data ?? {};
2267
+ if (type === "assistant/message") return messageText(data.message);
2268
+ if (type === "user/message") return messageText(data.message);
2269
+ if (type === "tool/result") {
2270
+ const result = data.result ?? data;
2271
+ return typeof result === "string" ? result : blockText(result);
2272
+ }
2273
+ return "";
2274
+ }
2275
+ function registerSessionHook(ctx) {
2276
+ const disposers = [];
2277
+ disposers.push(
2278
+ ctx.on("session/event", (session, event) => {
2279
+ const text = textFromSessionEvent(event);
2280
+ if (!text || text.length < 20) return;
2281
+ if (!extractIdentifiers(text).length) return;
2282
+ checkConfig().then((config) => {
2283
+ if (!config?.autoScanSession) return;
2284
+ return scanText(text);
2285
+ }).catch((e) => warn("session scan failed:", e.message));
2286
+ })
2287
+ );
2288
+ log("session hook registered (autoScanSession gates it)");
2289
+ return disposers;
2290
+ }
2291
+ var cachedConfig = null;
2292
+ var cachedAt = 0;
2293
+ async function checkConfig() {
2294
+ const now = Date.now();
2295
+ if (cachedConfig && now - cachedAt < 2e3) return cachedConfig;
2296
+ cachedAt = now;
2297
+ cachedConfig = await loadConfig();
2298
+ return cachedConfig;
2299
+ }
2300
+
2301
+ // src/node/index.js
2302
+ var name = "dsh-literature-pre";
2303
+ var inject = ["webServer", "tools"];
2304
+ function apply(ctx, config) {
2305
+ const onUncaught = (e) => error("uncaught exception:", e?.stack ?? e);
2306
+ const onUnhandled = (e) => error("unhandled rejection:", e?.stack ?? e);
2307
+ process.on("uncaughtException", onUncaught);
2308
+ process.on("unhandledRejection", onUnhandled);
2309
+ const disposers = [];
2310
+ ctx.effect(() => {
2311
+ ensureDirs().then(() => init()).catch((e) => error("storage init failed:", e.message));
2312
+ }, "dsh-literature: storage");
2313
+ const ready = (async () => {
2314
+ await ensureDirs();
2315
+ await init();
2316
+ disposers.push(...registerRoutes(ctx));
2317
+ disposers.push(...registerTools(ctx));
2318
+ try {
2319
+ disposers.push(...registerSessionHook(ctx));
2320
+ } catch (e) {
2321
+ error("session hook unavailable:", e.message);
2322
+ }
2323
+ disposers.push(startHeartbeat());
2324
+ log("dsh-literature host half ready");
2325
+ })().catch((e) => error("dsh-literature startup failed:", e?.stack ?? e));
2326
+ return () => {
2327
+ ready.catch(() => {
2328
+ });
2329
+ for (const d of disposers.splice(0)) {
2330
+ try {
2331
+ d?.();
2332
+ } catch (e) {
2333
+ error("dispose failed:", e.message);
2334
+ }
2335
+ }
2336
+ process.off("uncaughtException", onUncaught);
2337
+ process.off("unhandledRejection", onUnhandled);
2338
+ flush().catch(() => {
2339
+ });
2340
+ log("dsh-literature host half disposed");
2341
+ };
2342
+ }
2343
+ export {
2344
+ apply,
2345
+ inject,
2346
+ name
2347
+ };