dsh-cite 0.3.5 → 0.4.1

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.
@@ -0,0 +1,29 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { readImportInput } from './citation-model.js';
3
+ export async function parseCitationInput(value, signal) {
4
+ const input = readImportInput(value);
5
+ signal?.throwIfAborted();
6
+ const worker = new Worker(new URL('./citation-worker.js', import.meta.url), { workerData: input, execArgv: [], env: {}, resourceLimits: { maxOldGenerationSizeMb: 64 } });
7
+ try {
8
+ return await new Promise((resolve, reject) => {
9
+ const timer = setTimeout(() => finish(new Error('本地解析超过 5 秒,请拆分输入后重试。')), 5000);
10
+ const cancel = () => finish(signal?.reason instanceof Error ? signal.reason : new Error('本地解析已取消。'));
11
+ const finish = (error, rows) => { clearTimeout(timer); signal?.removeEventListener('abort', cancel); if (error)
12
+ reject(error);
13
+ else
14
+ resolve(rows); };
15
+ signal?.addEventListener('abort', cancel, { once: true });
16
+ if (signal?.aborted) {
17
+ cancel();
18
+ return;
19
+ }
20
+ worker.once('message', result => result.ok ? finish(undefined, result.rows) : finish(new Error('BibTeX / DOI 解析失败:' + result.message)));
21
+ worker.once('error', finish);
22
+ worker.once('exit', code => { if (code !== 0)
23
+ finish(new Error('本地解析未完成,请拆分或检查输入。')); });
24
+ });
25
+ }
26
+ finally {
27
+ await worker.terminate();
28
+ }
29
+ }
@@ -0,0 +1,10 @@
1
+ import { type CitationLibrary, type ImportInput, type ImportRow } from './citation-model.js';
2
+ export declare class CitationStore {
3
+ readonly root: string;
4
+ readonly file: string;
5
+ constructor(root?: string);
6
+ private init;
7
+ read(): Promise<CitationLibrary>;
8
+ edit(revision: unknown, mutate: (library: CitationLibrary) => void, signal?: AbortSignal): Promise<CitationLibrary>;
9
+ import(revision: number, input: ImportInput, rows: ImportRow[]): Promise<CitationLibrary>;
10
+ }
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile, rename, lstat, unlink } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import lockfile from 'proper-lockfile';
6
+ import { LibraryConflict, MAX_LIBRARY, MAX_BATCH, MAX_INPUT_BYTES, citationId, normalizeWork, readImportInput } from './citation-model.js';
7
+ const MAX_FILE = 8 * 1024 * 1024;
8
+ const empty = () => ({ version: 1, revision: 0, entries: [], sources: [] });
9
+ function validTime(v) { return typeof v === 'string' && Number.isFinite(Date.parse(v)); }
10
+ export class CitationStore {
11
+ root;
12
+ file;
13
+ constructor(root) { this.root = resolve(root || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'data/dsh-cite')); this.file = join(this.root, 'library.json'); }
14
+ async init() { await mkdir(this.root, { recursive: true, mode: 0o700 }); if ((await lstat(this.root)).isSymbolicLink())
15
+ throw new Error('文献库目录不能是符号链接。'); }
16
+ async read() {
17
+ await this.init();
18
+ let text;
19
+ try {
20
+ const info = await lstat(this.file);
21
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE)
22
+ throw new Error('文献库文件无效或过大,原文件已保留。');
23
+ text = await readFile(this.file, 'utf8');
24
+ }
25
+ catch (e) {
26
+ if (e.code === 'ENOENT')
27
+ return empty();
28
+ throw e;
29
+ }
30
+ try {
31
+ const v = JSON.parse(text);
32
+ if (v.version !== 1 || !Number.isInteger(v.revision) || v.revision < 0 || !Array.isArray(v.entries) || v.entries.length > MAX_LIBRARY || !Array.isArray(v.sources) || v.sources.length > MAX_LIBRARY)
33
+ throw new Error('结构不符');
34
+ const sourceIds = new Set(), entryIds = new Set(), dois = new Set();
35
+ for (const s of v.sources) {
36
+ citationId(s.id);
37
+ readImportInput(s);
38
+ if (!validTime(s.createdAt) || sourceIds.has(s.id))
39
+ throw new Error('来源记录无效');
40
+ sourceIds.add(s.id);
41
+ }
42
+ for (const e of v.entries) {
43
+ citationId(e.id);
44
+ normalizeWork(e.work);
45
+ if (entryIds.has(e.id) || !Array.isArray(e.sourceIds) || !e.sourceIds.length || e.sourceIds.some(id => !sourceIds.has(id)) || typeof e.bibKey !== 'string' || e.bibKey.length > 160 || !validTime(e.createdAt) || !validTime(e.updatedAt))
46
+ throw new Error('条目记录无效');
47
+ if (e.work.doi && dois.has(e.work.doi.toLowerCase()))
48
+ throw new Error('重复 DOI');
49
+ if (e.work.doi)
50
+ dois.add(e.work.doi.toLowerCase());
51
+ if (e.crossref && (typeof e.crossref.doi !== 'string' || !validTime(e.crossref.fetchedAt) || !Array.isArray(e.crossref.filled) || !Array.isArray(e.crossref.conflicts)))
52
+ throw new Error('补全记录无效');
53
+ entryIds.add(e.id);
54
+ }
55
+ return v;
56
+ }
57
+ catch (e) {
58
+ throw new Error('文献库记录损坏,原文件已保留,请先备份再检查。' + (e instanceof Error ? e.message : ''));
59
+ }
60
+ }
61
+ async edit(revision, mutate, signal) {
62
+ if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 0)
63
+ throw new Error('请提供当前文献库修订号。');
64
+ await this.init();
65
+ const unlock = await lockfile.lock(this.root, { retries: { retries: 20, minTimeout: 25, maxTimeout: 100 } });
66
+ const temp = join(this.root, randomUUID() + '.tmp');
67
+ try {
68
+ const state = await this.read();
69
+ signal?.throwIfAborted();
70
+ if (state.revision !== revision)
71
+ throw new LibraryConflict('文献库已在另一窗口更新。你的输入仍保留,请重新预览后保存。');
72
+ mutate(state);
73
+ state.revision++;
74
+ const bytes = JSON.stringify(state, null, 2);
75
+ if (Buffer.byteLength(bytes) > MAX_FILE)
76
+ throw new Error('文献库超过 8 MiB,请先备份;本次没有保存。');
77
+ signal?.throwIfAborted();
78
+ await writeFile(temp, bytes, { flag: 'wx', mode: 0o600 });
79
+ signal?.throwIfAborted();
80
+ await rename(temp, this.file);
81
+ return state;
82
+ }
83
+ finally {
84
+ await unlink(temp).catch(() => { });
85
+ await unlock();
86
+ }
87
+ }
88
+ async import(revision, input, rows) {
89
+ readImportInput(input);
90
+ if (!rows.length || rows.length > MAX_BATCH)
91
+ throw new Error('请选择 1–100 条可导入文献。');
92
+ return this.edit(revision, state => {
93
+ if (state.entries.length + rows.length > MAX_LIBRARY || state.sources.length >= MAX_LIBRARY)
94
+ throw new Error('文献库最多保存 500 条和 500 次来源,请先备份。');
95
+ const sourceBytes = state.sources.reduce((n, s) => n + Buffer.byteLength(s.text), 0);
96
+ if (sourceBytes + Buffer.byteLength(input.text) > MAX_FILE - MAX_INPUT_BYTES)
97
+ throw new Error('原始来源已接近容量限制,请先备份。');
98
+ const source = { ...input, id: randomUUID(), createdAt: new Date().toISOString() }, seen = new Set(state.entries.filter(e => e.work.doi).map(e => e.work.doi.toLowerCase()));
99
+ const entries = rows.map(r => { if (!r.work || r.error || r.duplicate)
100
+ throw new Error('选择包含错误或精确重复条目,请重新预览。'); const work = normalizeWork(r.work); if (work.doi && seen.has(work.doi))
101
+ throw new LibraryConflict('DOI 已存在,请重新预览。'); if (work.doi)
102
+ seen.add(work.doi); return { id: randomUUID(), work, bibKey: r.key.length <= 160 ? r.key : 'work', sourceIds: [source.id], createdAt: source.createdAt, updatedAt: source.createdAt }; });
103
+ state.sources.push(source);
104
+ state.entries.push(...entries);
105
+ });
106
+ }
107
+ }
@@ -0,0 +1,105 @@
1
+ import { type FetchLike, type Work } from './crossref.js';
2
+ import { CitationStore } from './citation-store.js';
3
+ import { type ImportRow, type FieldConflict } from './citation-model.js';
4
+ import { type ResolvedCiteConfig } from './config.js';
5
+ import { type CiteToolDefinition } from './tools.js';
6
+ export interface EnrichmentJob {
7
+ id: string;
8
+ state: 'running' | 'done' | 'failed' | 'cancelled';
9
+ total: number;
10
+ completed: number;
11
+ saved: number;
12
+ message?: string;
13
+ messageEn?: string;
14
+ conflict?: boolean;
15
+ results: Array<{
16
+ id: string;
17
+ doi: string;
18
+ ok: boolean;
19
+ filled?: string[];
20
+ conflicts?: FieldConflict[];
21
+ error?: string;
22
+ errorEn?: string;
23
+ }>;
24
+ }
25
+ export declare class CitationWorkbench {
26
+ readonly cfg: ResolvedCiteConfig;
27
+ readonly fetchImpl: FetchLike;
28
+ readonly route = "/api/dsh-cite/library";
29
+ readonly store: CitationStore;
30
+ private previews;
31
+ private jobs;
32
+ private lifetime;
33
+ private parsing;
34
+ constructor(cfg: ResolvedCiteConfig, root?: string, fetchImpl?: FetchLike);
35
+ private live;
36
+ list(): Promise<{
37
+ jobs: EnrichmentJob[];
38
+ revision: number;
39
+ entries: {
40
+ warnings: string[];
41
+ id: string;
42
+ work: Work;
43
+ bibKey: string;
44
+ sourceIds: string[];
45
+ createdAt: string;
46
+ updatedAt: string;
47
+ crossref?: {
48
+ doi: string;
49
+ fetchedAt: string;
50
+ filled: string[];
51
+ conflicts: FieldConflict[];
52
+ };
53
+ }[];
54
+ sources: {
55
+ id: string;
56
+ createdAt: string;
57
+ kind: "doi" | "bibtex";
58
+ label: string;
59
+ }[];
60
+ }>;
61
+ preview(raw: unknown, signal?: AbortSignal): Promise<{
62
+ id: string;
63
+ revision: number;
64
+ label: string;
65
+ rows: ImportRow[];
66
+ expiresInSeconds: number;
67
+ }>;
68
+ import(id: unknown, selection: unknown): Promise<{
69
+ revision: number;
70
+ entries: {
71
+ warnings: string[];
72
+ id: string;
73
+ work: Work;
74
+ bibKey: string;
75
+ sourceIds: string[];
76
+ createdAt: string;
77
+ updatedAt: string;
78
+ crossref?: {
79
+ doi: string;
80
+ fetchedAt: string;
81
+ filled: string[];
82
+ conflicts: FieldConflict[];
83
+ };
84
+ }[];
85
+ sources: {
86
+ id: string;
87
+ createdAt: string;
88
+ kind: "doi" | "bibtex";
89
+ label: string;
90
+ }[];
91
+ }>;
92
+ enrich(rawIds: unknown, revision: unknown): Promise<EnrichmentJob>;
93
+ private runEnrichment;
94
+ job(id: unknown): EnrichmentJob;
95
+ cancel(id: unknown): EnrichmentJob;
96
+ dispose(): void;
97
+ export(kind: unknown, style: unknown, lang: unknown, rawIds?: unknown): Promise<{
98
+ filename: string;
99
+ type: string;
100
+ content: string;
101
+ }>;
102
+ dispatch(value: unknown, signal?: AbortSignal): Promise<unknown>;
103
+ fetch(request: Request): Promise<Response>;
104
+ }
105
+ export declare function buildBatchTool(workbench: CitationWorkbench): CiteToolDefinition;
@@ -0,0 +1,277 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lookupDoi } from './crossref.js';
3
+ import { buildBibtex, buildCitation, readStyle } from './format.js';
4
+ import { parseCitationInput } from './citation-parser.js';
5
+ import { CitationStore } from './citation-store.js';
6
+ import { LibraryConflict, candidateKey, citationId, normalizeWork, readImportInput, readSelection, workWarnings } from './citation-model.js';
7
+ import { englishCitationError } from './citation-errors.js';
8
+ const message = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 1800);
9
+ const summary = (state) => ({ revision: state.revision, entries: state.entries.map(e => ({ ...e, warnings: workWarnings(e.work) })), sources: state.sources.map(({ text: _text, ...s }) => s) });
10
+ const emptyField = (v) => v === '' || v === 0 || v === undefined || (Array.isArray(v) && !v.length);
11
+ function equivalent(a, b) { return typeof a === 'string' && typeof b === 'string' ? a.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase() === b.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase() : JSON.stringify(a) === JSON.stringify(b); }
12
+ function mergeWork(local, incoming) {
13
+ const work = structuredClone(local), filled = [], conflicts = [];
14
+ for (const key of Object.keys(work)) {
15
+ if (key === 'doi' || emptyField(incoming[key]))
16
+ continue;
17
+ // 'other' is an import placeholder, rather than a bibliographic classification.
18
+ if (emptyField(work[key]) || key === 'type' && work.type === 'other') {
19
+ work[key] = incoming[key];
20
+ filled.push(key);
21
+ }
22
+ else if (!equivalent(work[key], incoming[key]))
23
+ conflicts.push({ field: key, local: work[key], crossref: incoming[key] });
24
+ }
25
+ return { work, filled, conflicts };
26
+ }
27
+ const fetchCrossref = async (url, init) => {
28
+ const response = await fetch(url, { ...init, redirect: 'error' });
29
+ if (!response.ok || !response.body)
30
+ return response;
31
+ const reader = response.body.getReader(), chunks = [];
32
+ let bytes = 0;
33
+ try {
34
+ while (true) {
35
+ init?.signal?.throwIfAborted();
36
+ const item = await reader.read();
37
+ if (item.done)
38
+ break;
39
+ bytes += item.value.length;
40
+ if (bytes > 2 * 1024 * 1024)
41
+ throw new Error('Crossref 记录超过 2 MiB,请检查该 DOI。');
42
+ chunks.push(item.value);
43
+ }
44
+ }
45
+ finally {
46
+ await reader.cancel().catch(() => { });
47
+ }
48
+ return new Response(Buffer.concat(chunks), { status: response.status, headers: { 'content-type': 'application/json' } });
49
+ };
50
+ export class CitationWorkbench {
51
+ cfg;
52
+ fetchImpl;
53
+ route = '/api/dsh-cite/library';
54
+ store;
55
+ previews = new Map();
56
+ jobs = new Map();
57
+ lifetime = new AbortController();
58
+ parsing = false;
59
+ constructor(cfg, root, fetchImpl = fetchCrossref) {
60
+ this.cfg = cfg;
61
+ this.fetchImpl = fetchImpl;
62
+ this.store = new CitationStore(root);
63
+ }
64
+ live() { this.lifetime.signal.throwIfAborted(); }
65
+ async list() { this.live(); return { ...summary(await this.store.read()), jobs: [...this.jobs.values()].map(j => structuredClone(j.view)) }; }
66
+ async preview(raw, signal) {
67
+ this.live();
68
+ if (this.parsing)
69
+ throw new Error('正在解析上一批输入,请稍后重试。');
70
+ const input = readImportInput(raw), state = await this.store.read();
71
+ this.parsing = true;
72
+ let rows;
73
+ try {
74
+ rows = await parseCitationInput(input, signal ? AbortSignal.any([signal, this.lifetime.signal]) : this.lifetime.signal);
75
+ }
76
+ finally {
77
+ this.parsing = false;
78
+ }
79
+ const dois = new Set(), candidates = new Set(), libraryDois = new Set(state.entries.filter(e => e.work.doi).map(e => e.work.doi.toLowerCase())), libraryKeys = new Set(state.entries.map(e => candidateKey(e.work)).filter(Boolean));
80
+ for (const row of rows) {
81
+ if (!row.work) {
82
+ row.selectedDefault = false;
83
+ if (row.error)
84
+ row.errorEn = englishCitationError(row.error);
85
+ continue;
86
+ }
87
+ const doi = row.work.doi, fingerprint = candidateKey(row.work);
88
+ if (doi) {
89
+ if (libraryDois.has(doi))
90
+ row.duplicate = 'library';
91
+ else if (dois.has(doi))
92
+ row.duplicate = 'batch';
93
+ dois.add(doi);
94
+ }
95
+ else if (fingerprint && (candidates.has(fingerprint) || libraryKeys.has(fingerprint)))
96
+ row.candidate = true;
97
+ if (fingerprint)
98
+ candidates.add(fingerprint);
99
+ if (row.key.length > 160)
100
+ row.warnings.push('long_citation_key');
101
+ row.selectedDefault = !row.duplicate && !row.candidate;
102
+ }
103
+ for (const [id, p] of this.previews)
104
+ if (Date.now() - p.createdAt > 600000)
105
+ this.previews.delete(id);
106
+ if (this.previews.size >= 10)
107
+ this.previews.delete(this.previews.keys().next().value);
108
+ const p = { id: randomUUID(), revision: state.revision, input, rows, createdAt: Date.now() };
109
+ this.previews.set(p.id, p);
110
+ return { id: p.id, revision: p.revision, label: input.label, rows: structuredClone(rows), expiresInSeconds: 600 };
111
+ }
112
+ async import(id, selection) {
113
+ this.live();
114
+ const p = this.previews.get(citationId(id));
115
+ if (!p || Date.now() - p.createdAt > 600000)
116
+ throw new Error('预览已过期或 DSH 已重启。原输入仍保留,请重新预览。');
117
+ if (!Array.isArray(selection) || !selection.length || selection.length > 100 || selection.some(i => !Number.isInteger(i) || i < 0 || i >= p.rows.length) || new Set(selection).size !== selection.length)
118
+ throw new Error('请选择 1–100 个有效预览条目。');
119
+ const state = await this.store.import(p.revision, p.input, selection.map(i => p.rows[i]));
120
+ this.previews.delete(p.id);
121
+ return summary(state);
122
+ }
123
+ async enrich(rawIds, revision) {
124
+ this.live();
125
+ if ([...this.jobs.values()].some(j => j.view.state === 'running'))
126
+ throw new Error('已有补全任务运行中,请等待或取消。');
127
+ const ids = readSelection(rawIds), state = await this.store.read();
128
+ if (state.revision !== revision)
129
+ throw new LibraryConflict('文献库已更新,请刷新后重新选择。');
130
+ const entries = ids.map(id => { const entry = state.entries.find(e => e.id === id); if (!entry)
131
+ throw new Error('选择中包含不存在的文献,请刷新。'); return entry; });
132
+ const job = { controller: new AbortController(), view: { id: randomUUID(), state: 'running', total: entries.length, completed: 0, saved: 0, results: [] } };
133
+ if (this.jobs.size >= 10)
134
+ this.jobs.delete(this.jobs.keys().next().value);
135
+ this.jobs.set(job.view.id, job);
136
+ void this.runEnrichment(job, state.revision, entries);
137
+ return structuredClone(job.view);
138
+ }
139
+ async runEnrichment(job, revision, entries) {
140
+ const signal = AbortSignal.any([job.controller.signal, this.lifetime.signal]), patches = new Map();
141
+ let next = 0;
142
+ try {
143
+ await Promise.all(Array.from({ length: Math.min(2, entries.length) }, async () => {
144
+ while (next < entries.length) {
145
+ signal.throwIfAborted();
146
+ const e = entries[next++];
147
+ try {
148
+ if (!e.work.doi)
149
+ throw new Error('此条目没有 DOI,无法从 Crossref 补全。');
150
+ const incoming = normalizeWork(await lookupDoi(e.work.doi, this.cfg, this.fetchImpl, signal));
151
+ signal.throwIfAborted();
152
+ if (incoming.doi !== e.work.doi.toLowerCase())
153
+ throw new Error('Crossref 返回的 DOI 不匹配,已保留本地记录。');
154
+ const merged = mergeWork(e.work, incoming);
155
+ patches.set(e.id, merged);
156
+ job.view.results.push({ id: e.id, doi: e.work.doi, ok: true, filled: merged.filled, conflicts: merged.conflicts });
157
+ }
158
+ catch (e2) {
159
+ signal.throwIfAborted();
160
+ const error = message(e2);
161
+ job.view.results.push({ id: e.id, doi: e.work.doi, ok: false, error, errorEn: englishCitationError(error) });
162
+ }
163
+ job.view.completed++;
164
+ }
165
+ }));
166
+ signal.throwIfAborted();
167
+ if (patches.size)
168
+ await this.store.edit(revision, state => {
169
+ signal.throwIfAborted();
170
+ for (const e of state.entries) {
171
+ const patch = patches.get(e.id);
172
+ if (patch) {
173
+ e.work = patch.work;
174
+ e.updatedAt = new Date().toISOString();
175
+ e.crossref = { doi: e.work.doi, fetchedAt: e.updatedAt, filled: patch.filled, conflicts: patch.conflicts };
176
+ }
177
+ }
178
+ }, signal);
179
+ job.view.saved = patches.size;
180
+ job.view.state = 'done';
181
+ }
182
+ catch (e) {
183
+ job.view.state = signal.aborted ? 'cancelled' : 'failed';
184
+ job.view.conflict = e instanceof LibraryConflict;
185
+ job.view.message = signal.aborted ? '已取消;本次补全没有写入文献库。' : message(e);
186
+ job.view.messageEn = englishCitationError(job.view.message);
187
+ }
188
+ }
189
+ job(id) { this.live(); const j = this.jobs.get(citationId(id)); if (!j)
190
+ throw new Error('任务不存在或 DSH 已重启。文献仍保留,请刷新。'); return structuredClone(j.view); }
191
+ cancel(id) { this.live(); const j = this.jobs.get(citationId(id)); if (!j)
192
+ throw new Error('任务不存在。'); if (j.view.state === 'running')
193
+ j.controller.abort(); return structuredClone(j.view); }
194
+ dispose() { this.lifetime.abort(new Error('插件已卸载。')); for (const j of this.jobs.values())
195
+ j.controller.abort(); }
196
+ async export(kind, style, lang, rawIds) {
197
+ this.live();
198
+ const state = await this.store.read(), ids = rawIds === undefined ? undefined : readSelection(rawIds);
199
+ const entries = ids ? ids.map(id => { const e = state.entries.find(e => e.id === id); if (!e)
200
+ throw new Error('所选文献已不存在,请刷新。'); return e; }) : state.entries;
201
+ if (kind === 'backup') {
202
+ if (ids)
203
+ throw new Error('完整备份包含全库及原始来源,请不要附带条目选择。');
204
+ return { filename: 'cite-library.json', type: 'application/json; charset=utf-8', content: JSON.stringify(state, null, 2) };
205
+ }
206
+ if (!entries.length)
207
+ throw new Error('文献库为空,请先导入。');
208
+ if (kind === 'references')
209
+ return { filename: 'references-' + readStyle(style) + '.txt', type: 'text/plain; charset=utf-8', content: entries.map((e, i) => `[${i + 1}] ` + buildCitation(e.work, readStyle(style), lang === 'en' ? 'en' : 'zh')).join('\n\n') + '\n' };
210
+ if (kind !== 'bibtex')
211
+ throw new Error('导出类型只能是 bibtex / references / backup。');
212
+ const keys = new Map(), used = new Set();
213
+ for (const e of state.entries) {
214
+ const base = e.bibKey.replace(/[^A-Za-z0-9_.:\/-]+/g, '_').replace(/^_+|_+$/g, '') || 'work';
215
+ let key = base;
216
+ let n = 0;
217
+ while (used.has(key))
218
+ key = base + '-' + e.id.slice(0, 8) + (n++ ? '-' + n : '');
219
+ used.add(key);
220
+ keys.set(e.id, key);
221
+ }
222
+ return { filename: 'references.bib', type: 'application/x-bibtex; charset=utf-8', content: entries.map(e => buildBibtex(e.work, keys.get(e.id))).join('\n\n') + '\n' };
223
+ }
224
+ async dispatch(value, signal) {
225
+ this.live();
226
+ if (!value || typeof value !== 'object')
227
+ throw new Error('操作参数无效。');
228
+ const v = value;
229
+ switch (v.action) {
230
+ case 'list': return this.list();
231
+ case 'preview': return this.preview({ kind: v.kind, text: v.text, label: v.label }, signal);
232
+ case 'import': return this.import(v.preview, v.selection);
233
+ case 'enrich': return this.enrich(v.ids, v.revision);
234
+ case 'job': return this.job(v.job);
235
+ case 'cancel': return this.cancel(v.job);
236
+ case 'export': return this.export(v.kind, v.style, v.lang, v.ids);
237
+ default: throw new Error('操作无效。');
238
+ }
239
+ }
240
+ async fetch(request) {
241
+ try {
242
+ const url = new URL(request.url), origin = request.headers.get('origin'), site = request.headers.get('sec-fetch-site'), desktop = origin === 'dsh-app://app';
243
+ if (site && !['same-origin', 'none'].includes(site) && !desktop)
244
+ return Response.json({ ok: false, message: '请从 DSH 页面操作。' }, { status: 403 });
245
+ if (origin && !desktop) {
246
+ const source = new URL(origin);
247
+ if (!['http:', 'https:'].includes(source.protocol) || source.host !== (request.headers.get('host') || url.host))
248
+ return Response.json({ ok: false, message: '请从 DSH 页面操作。' }, { status: 403 });
249
+ }
250
+ if (request.method === 'GET') {
251
+ const ids = url.searchParams.get('ids'), data = await this.export(url.searchParams.get('kind'), url.searchParams.get('style'), url.searchParams.get('lang'), ids ? ids.split(',') : undefined);
252
+ return new Response(data.content, { headers: { 'content-type': data.type, 'content-disposition': `attachment; filename="${data.filename}"`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' } });
253
+ }
254
+ if (request.method !== 'POST')
255
+ return new Response(null, { status: 405 });
256
+ if (request.headers.get('x-dsh-cite') !== '1')
257
+ return Response.json({ ok: false, message: '操作来源无效。' }, { status: 403 });
258
+ if (!request.headers.get('content-type')?.startsWith('application/json'))
259
+ throw new Error('请求格式无效。');
260
+ const text = await request.text();
261
+ if (Buffer.byteLength(text) > 2 * 1024 * 1024)
262
+ throw new Error('请求超过容量限制,请拆分输入。');
263
+ return Response.json({ ok: true, value: await this.dispatch(JSON.parse(text), request.signal) }, { headers: { 'cache-control': 'no-store' } });
264
+ }
265
+ catch (e) {
266
+ return Response.json({ ok: false, message: message(e), messageEn: englishCitationError(message(e)) }, { status: e instanceof LibraryConflict ? 409 : 400, headers: { 'cache-control': 'no-store' } });
267
+ }
268
+ }
269
+ }
270
+ export function buildBatchTool(workbench) {
271
+ return {
272
+ name: 'cite_batch', description: '本地文献库:显式 preview 解析 DOI 列表/BibTeX(不联网),import 选择预览条目保存,list 读取,enrich 才联网从 Crossref 补空字段且保留冲突原值,job/cancel 查进度或取消,export 导出基本引文/BibTeX/完整原始来源备份。单批最多100条、库最多500条;不得捏造缺失数据。',
273
+ parameters: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'preview', 'import', 'enrich', 'job', 'cancel', 'export'] }, kind: { type: 'string', enum: ['doi', 'bibtex', 'references', 'backup'] }, text: { type: 'string', description: 'DOI 每行一条;或 BibTeX 文本,最大256KiB。' }, label: { type: 'string' }, preview: { type: 'string' }, selection: { type: 'array', items: { type: 'integer' }, description: 'preview 返回的零起始 index(1–100个)。' }, ids: { type: 'array', items: { type: 'string' }, description: '已保存文献 id(1–100个)。' }, revision: { type: 'integer' }, job: { type: 'string' }, style: { type: 'string', enum: ['gb-t-7714', 'apa', 'mla', 'chicago'] }, lang: { type: 'string', enum: ['zh', 'en'] } }, required: ['action'] },
274
+ output: { schema: { type: 'object', additionalProperties: true }, render(_args, value) { return [{ type: 'text', text: JSON.stringify(value, null, 2) }]; } },
275
+ async execute(args, exec) { const signal = typeof exec === 'object' && exec && exec.signal instanceof AbortSignal ? exec.signal : undefined; signal?.throwIfAborted(); return workbench.dispatch(args, signal); }, timeoutMs: 15000,
276
+ };
277
+ }
@@ -0,0 +1 @@
1
+ import '@citation-js/plugin-bibtex';
@@ -0,0 +1,44 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { Cite } from '@citation-js/core';
3
+ import '@citation-js/plugin-bibtex';
4
+ import { assertDoi } from './crossref.js';
5
+ import { MAX_BATCH, normalizeWork, plainText, workWarnings } from './citation-model.js';
6
+ const types = { 'article-journal': 'journal-article', book: 'book', chapter: 'book-chapter', 'paper-conference': 'proceedings-article', thesis: 'dissertation', report: 'report', dataset: 'dataset', patent: 'patent', standard: 'standard' };
7
+ // Citation.js treats BibTeX's publisher "and" separator as a list. The local
8
+ // Work model is plain text; preserve these names and retain the original source.
9
+ const cslText = (value) => Array.isArray(value) ? value.map(plainText).join(' and ') : plainText(value);
10
+ function workOf(csl) {
11
+ return normalizeWork({
12
+ doi: csl.DOI, type: types[csl.type] || 'other', title: csl.title,
13
+ authors: (csl.author || []).map(a => { const family = [a['non-dropping-particle'], a.family, a.suffix].filter(Boolean).join(' '); const given = [a.given, a['dropping-particle']].filter(Boolean).join(' '); return a.literal || !given ? { name: a.literal || family, family: '', given: '' } : { name: '', family, given }; }),
14
+ containerTitle: cslText(csl['container-title']), publisher: cslText(csl.publisher), year: Number(csl.issued?.['date-parts']?.[0]?.[0] || 0), language: csl.language, volume: csl.volume, issue: csl.issue, page: csl.page, url: csl.URL, isbn: csl.ISBN,
15
+ });
16
+ }
17
+ try {
18
+ const { kind, text } = workerData;
19
+ let records;
20
+ if (kind === 'doi') {
21
+ const lines = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
22
+ records = lines.map(line => ({ key: line, getWork: () => normalizeWork({ doi: assertDoi(line), url: 'https://doi.org/' + assertDoi(line).toLowerCase() }) }));
23
+ }
24
+ else {
25
+ // Force the local parser. Incomplete metadata is allowed; malformed syntax is not.
26
+ const data = new Cite(text, { forceType: '@bibtex/text', generateGraph: false, maxChainLength: 10, strict: false, target: '@csl/list+object' }).data;
27
+ records = data.map(csl => ({ key: String(csl['citation-key'] || csl.id || ''), getWork: () => workOf(csl) }));
28
+ }
29
+ if (!records.length)
30
+ throw new Error('没有找到可导入的文献。DOI 每行一条;BibTeX 应以 @article、@book 等条目开头。');
31
+ if (records.length > MAX_BATCH)
32
+ throw new Error('单批不能超过 100 条,请拆分导入;没有保存任何条目。');
33
+ const rows = records.map((row, index) => { try {
34
+ const work = row.getWork();
35
+ return { index, key: row.key, work, warnings: workWarnings(work) };
36
+ }
37
+ catch (e) {
38
+ return { index, key: row.key, work: null, warnings: [], error: e instanceof Error ? e.message : String(e) };
39
+ } });
40
+ parentPort.postMessage({ ok: true, rows });
41
+ }
42
+ catch (e) {
43
+ parentPort.postMessage({ ok: false, message: e instanceof Error ? e.message : String(e) });
44
+ }