dsh-plugin-litsearch 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -41
- package/client/client.js +2632 -926
- package/lib/host/events.d.ts +9 -1
- package/lib/host/events.js +50 -3
- package/lib/host/export/bibtex.js +1 -4
- package/lib/host/export/common.d.ts +0 -2
- package/lib/host/export/common.js +0 -7
- package/lib/host/export/csv.js +1 -5
- package/lib/host/export/markdown.d.ts +1 -1
- package/lib/host/export/markdown.js +9 -26
- package/lib/host/export/ris.js +1 -4
- package/lib/host/paths.d.ts +1 -1
- package/lib/host/paths.js +1 -1
- package/lib/host/pdf/store.d.ts +5 -3
- package/lib/host/pdf/store.js +50 -17
- package/lib/host/router.js +146 -79
- package/lib/host/tasks/download.d.ts +104 -0
- package/lib/host/tasks/download.js +274 -0
- package/lib/host/tasks/filter.d.ts +8 -1
- package/lib/host/tasks/filter.js +14 -0
- package/lib/host/tasks/service.d.ts +44 -11
- package/lib/host/tasks/service.js +216 -48
- package/lib/host/tasks/states.d.ts +1 -3
- package/lib/host/tasks/states.js +2 -66
- package/lib/host/tasks/stats.js +3 -0
- package/lib/host/tasks/summary.js +1 -1
- package/lib/host/tools.d.ts +5 -1
- package/lib/host/tools.js +813 -37
- package/lib/host/zotero/connector.d.ts +19 -10
- package/lib/host/zotero/connector.js +142 -39
- package/lib/host/zotero/match.d.ts +27 -0
- package/lib/host/zotero/match.js +59 -0
- package/lib/search/config.d.ts +0 -2
- package/lib/search/config.js +0 -2
- package/lib/search/oa.d.ts +8 -0
- package/lib/search/oa.js +53 -6
- package/lib/search/types.d.ts +154 -10
- package/lib/search/types.js +11 -0
- package/package.json +1 -1
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* 模型工具与面板 HTTP 路由都只调用这里,保证两边操作同一份状态。
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import { MAX_LIMIT,
|
|
10
|
+
import { MAX_LIMIT, getSettings } from '../../search/config.js';
|
|
11
11
|
import { SearchError } from '../../search/errors.js';
|
|
12
12
|
import { cooldownUntil, sourceHealthList } from '../../search/health.js';
|
|
13
13
|
import { PROVIDER_INFOS } from '../../search/providers/index.js';
|
|
@@ -17,11 +17,11 @@ import { fetchPubtatorEntities } from '../../search/pubtator-entities.js';
|
|
|
17
17
|
import { SOURCE_IDS } from '../../search/types.js';
|
|
18
18
|
import { TaskEvents } from '../events.js';
|
|
19
19
|
import { buildExport } from '../export/index.js';
|
|
20
|
-
import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, prunePdfCache,
|
|
20
|
+
import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, prunePdfCache, readSavedPdf, readableFilename, resolvePdf, resolvePdfRoot, } from '../pdf/store.js';
|
|
21
21
|
import { ZoteroConnector } from '../zotero/connector.js';
|
|
22
22
|
import { normalizeWorkspace } from '../workspace.js';
|
|
23
23
|
import { compareTasks } from './compare.js';
|
|
24
|
-
import { filterPapers } from './filter.js';
|
|
24
|
+
import { filterPapers, sortPapers } from './filter.js';
|
|
25
25
|
import { TaskRunner } from './runner.js';
|
|
26
26
|
import { computeStats } from './stats.js';
|
|
27
27
|
import { applyAnnotations, countScreening, emptyState, normalizeGroups } from './states.js';
|
|
@@ -32,11 +32,64 @@ const MAX_TITLE = 120;
|
|
|
32
32
|
const MAX_PAGE_LIMIT = 200;
|
|
33
33
|
const MAX_TYPES = 20;
|
|
34
34
|
const PROBE_QUERY = 'machine learning';
|
|
35
|
+
/** 已下载文件存在性检查的短缓存:详情轮询时不必反复 stat 同一批文件。 */
|
|
36
|
+
const PDF_EXISTS_TTL_MS = 10_000;
|
|
37
|
+
const pdfExistsCache = new Map();
|
|
38
|
+
/** 右侧栏视图快照有效期:面板长时间不动时不拿旧页码替模型算「当前页」。 */
|
|
39
|
+
const VIEW_TTL_MS = 30 * 60 * 1000;
|
|
40
|
+
/** 最多保留多少个会话的视图快照,超出按上报时间淘汰最旧的。 */
|
|
41
|
+
const MAX_VIEWS = 64;
|
|
42
|
+
async function pdfFileExists(filePath) {
|
|
43
|
+
const cached = pdfExistsCache.get(filePath);
|
|
44
|
+
if (cached !== undefined && Date.now() - cached.at < PDF_EXISTS_TTL_MS)
|
|
45
|
+
return cached.exists;
|
|
46
|
+
const exists = await stat(filePath)
|
|
47
|
+
.then((info) => info.isFile())
|
|
48
|
+
.catch(() => false);
|
|
49
|
+
pdfExistsCache.set(filePath, { exists, at: Date.now() });
|
|
50
|
+
return exists;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 已下载的 PDF 可能被用户在文件管理器里删掉(也可能从废纸篓恢复):
|
|
54
|
+
* 每次读取任务时校准状态,删除后回到 `missing`,点击即可重新下载。
|
|
55
|
+
*/
|
|
56
|
+
async function reconcilePdfFiles(task) {
|
|
57
|
+
const downloads = Object.entries(task.states).filter(([, state]) => state.pdfFile !== undefined &&
|
|
58
|
+
(state.pdfStatus === 'downloaded' || state.pdfStatus === 'missing'));
|
|
59
|
+
if (downloads.length === 0)
|
|
60
|
+
return false;
|
|
61
|
+
const checks = await Promise.all(downloads.map(async ([paperId, state]) => ({
|
|
62
|
+
paperId,
|
|
63
|
+
exists: await pdfFileExists(state.pdfFile ?? ''),
|
|
64
|
+
})));
|
|
65
|
+
let changed = false;
|
|
66
|
+
for (const { paperId, exists } of checks) {
|
|
67
|
+
const current = task.states[paperId];
|
|
68
|
+
if (current === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
if (current.pdfStatus === 'downloaded' && !exists) {
|
|
71
|
+
task.states[paperId] = { ...current, pdfStatus: 'missing' };
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
else if (current.pdfStatus === 'missing' && exists) {
|
|
75
|
+
task.states[paperId] = { ...current, pdfStatus: 'downloaded' };
|
|
76
|
+
changed = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return changed;
|
|
80
|
+
}
|
|
35
81
|
export class TaskService {
|
|
36
82
|
ready;
|
|
37
83
|
store = new TaskStore();
|
|
38
84
|
events = new TaskEvents();
|
|
39
85
|
runner = new TaskRunner();
|
|
86
|
+
/**
|
|
87
|
+
* 每个会话最近一次的右侧栏视图快照(内存、不落盘)。
|
|
88
|
+
*
|
|
89
|
+
* 模型工具跑在 Host 侧看不到界面,靠它把「当前页」「当前筛选全部」算准;
|
|
90
|
+
* 宿主重启即失效,与事件日志 rev 的生命周期一致。
|
|
91
|
+
*/
|
|
92
|
+
views = new Map();
|
|
40
93
|
/**
|
|
41
94
|
* 全局「当前任务」:最后一次使用或打开的任务。
|
|
42
95
|
*
|
|
@@ -153,7 +206,6 @@ export class TaskService {
|
|
|
153
206
|
tag: settings.zoteroTag,
|
|
154
207
|
},
|
|
155
208
|
storageDir: this.store.root,
|
|
156
|
-
version: PLUGIN_VERSION,
|
|
157
209
|
};
|
|
158
210
|
}
|
|
159
211
|
async eventsSince(sinceRev) {
|
|
@@ -183,8 +235,12 @@ export class TaskService {
|
|
|
183
235
|
task.rounds = [];
|
|
184
236
|
return task;
|
|
185
237
|
}
|
|
186
|
-
async getDetail(id) {
|
|
187
|
-
|
|
238
|
+
async getDetail(id, workspace) {
|
|
239
|
+
const task = await this.get(id);
|
|
240
|
+
if (await reconcilePdfFiles(task))
|
|
241
|
+
await this.store.save(task);
|
|
242
|
+
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
243
|
+
return { ...toDetail(task), downloadDir: resolvePdfRoot(target) };
|
|
188
244
|
}
|
|
189
245
|
/** 任务内轮次历史(时间升序、深拷贝)。 */
|
|
190
246
|
async getRounds(id) {
|
|
@@ -219,6 +275,53 @@ export class TaskService {
|
|
|
219
275
|
focusTask(id, title, sessionId) {
|
|
220
276
|
this.events.requestFocus(id, title, sessionId);
|
|
221
277
|
}
|
|
278
|
+
/** 下发一条模型批量下载指令;右侧栏轮询 /events 拿增量后自行执行。 */
|
|
279
|
+
publishDownloadCommand(command) {
|
|
280
|
+
this.events.publishDownload(command);
|
|
281
|
+
}
|
|
282
|
+
/** 下发一条模型批量存入 Zotero 指令;与下载指令走独立的事件日志。 */
|
|
283
|
+
publishZoteroCommand(command) {
|
|
284
|
+
this.events.publishZotero(command);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* 面板上报当前视图。会话 id 以请求头为准覆盖 body,防止伪造别的会话的视图;
|
|
288
|
+
* 时间也由 Host 重置,避免客户端时钟偏差影响过期判断。
|
|
289
|
+
*/
|
|
290
|
+
saveViewSnapshot(sessionId, snapshot) {
|
|
291
|
+
if (sessionId === '' || snapshot.taskId === '')
|
|
292
|
+
return;
|
|
293
|
+
this.views.set(sessionId, { ...snapshot, sessionId, at: Date.now() });
|
|
294
|
+
if (this.views.size <= MAX_VIEWS)
|
|
295
|
+
return;
|
|
296
|
+
let oldestKey;
|
|
297
|
+
let oldestAt = Number.MAX_SAFE_INTEGER;
|
|
298
|
+
for (const [key, value] of this.views) {
|
|
299
|
+
if (value.at < oldestAt) {
|
|
300
|
+
oldestAt = value.at;
|
|
301
|
+
oldestKey = key;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (oldestKey !== undefined)
|
|
305
|
+
this.views.delete(oldestKey);
|
|
306
|
+
}
|
|
307
|
+
/** 取该会话在该任务上的最近视图;过期、跨任务或 rev 超前都视为不可信。 */
|
|
308
|
+
async getViewSnapshot(sessionId, taskId) {
|
|
309
|
+
await this.ready;
|
|
310
|
+
if (sessionId === undefined || sessionId === '')
|
|
311
|
+
return undefined;
|
|
312
|
+
const snapshot = this.views.get(sessionId);
|
|
313
|
+
if (snapshot === undefined || snapshot.taskId !== taskId)
|
|
314
|
+
return undefined;
|
|
315
|
+
if (Date.now() - snapshot.at > VIEW_TTL_MS) {
|
|
316
|
+
this.views.delete(sessionId);
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
// 每下载一篇都会让 rev +1,因此只做「快照不比任务更新」的宽松校验。
|
|
320
|
+
const task = await this.tryGet(taskId);
|
|
321
|
+
if (task === undefined || snapshot.rev > task.rev)
|
|
322
|
+
return undefined;
|
|
323
|
+
return snapshot;
|
|
324
|
+
}
|
|
222
325
|
persistCurrent() {
|
|
223
326
|
void this.store.saveCurrent({
|
|
224
327
|
version: 1,
|
|
@@ -365,6 +468,10 @@ export class TaskService {
|
|
|
365
468
|
// 模型工具导出到 <storage>/out/<taskId>/ 的文件随任务一起清掉。
|
|
366
469
|
await rm(join(this.store.root, 'out', task.id), { recursive: true, force: true });
|
|
367
470
|
this.events.remove(task.id);
|
|
471
|
+
for (const [key, value] of this.views) {
|
|
472
|
+
if (value.taskId === task.id)
|
|
473
|
+
this.views.delete(key);
|
|
474
|
+
}
|
|
368
475
|
if (this.currentTaskId !== task.id)
|
|
369
476
|
return;
|
|
370
477
|
this.currentTaskId = undefined;
|
|
@@ -423,13 +530,7 @@ export class TaskService {
|
|
|
423
530
|
const limit = clampInt(filter.limit ?? task.conditions.pageSize, 1, MAX_PAGE_LIMIT);
|
|
424
531
|
const offset = clampInt(filter.offset ?? 0, 0, Number.MAX_SAFE_INTEGER);
|
|
425
532
|
const papers = filterPapers(task, await this.withBaseFilter(task, filter));
|
|
426
|
-
|
|
427
|
-
if (sort === 'citations') {
|
|
428
|
-
papers.sort((a, b) => (b.citedBy ?? -1) - (a.citedBy ?? -1));
|
|
429
|
-
}
|
|
430
|
-
else if (sort === 'year') {
|
|
431
|
-
papers.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
|
432
|
-
}
|
|
533
|
+
sortPapers(papers, filter.sort ?? task.conditions.sort);
|
|
433
534
|
return {
|
|
434
535
|
taskId: task.id,
|
|
435
536
|
rev: task.rev,
|
|
@@ -445,7 +546,7 @@ export class TaskService {
|
|
|
445
546
|
const resolved = base === undefined ? filter : await this.withBaseFilter(task, filter);
|
|
446
547
|
return computeStats(task, resolved, scope, base);
|
|
447
548
|
}
|
|
448
|
-
/** 写入筛读 /
|
|
549
|
+
/** 写入筛读 / 分组;返回新的 rev 与命中条数。 */
|
|
449
550
|
async annotate(id, inputs, expectedRev) {
|
|
450
551
|
const task = await this.get(id);
|
|
451
552
|
if (expectedRev !== undefined && expectedRev !== task.rev) {
|
|
@@ -519,6 +620,27 @@ export class TaskService {
|
|
|
519
620
|
remaining: this.store.list().length,
|
|
520
621
|
};
|
|
521
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* 「查看已下载」前的存在性检查:页面加载后文件可能被删除或移走。
|
|
625
|
+
* 缺失时把状态改为 `missing` 并广播,所有面板随即显示「下载文件已删除」。
|
|
626
|
+
*/
|
|
627
|
+
async recheckPdf(id, paperId) {
|
|
628
|
+
const task = await this.get(id);
|
|
629
|
+
findPaper(task, paperId);
|
|
630
|
+
const state = task.states[paperId];
|
|
631
|
+
const filePath = state?.pdfFile;
|
|
632
|
+
if (state === undefined || filePath === undefined)
|
|
633
|
+
return { exists: false };
|
|
634
|
+
pdfExistsCache.delete(filePath);
|
|
635
|
+
const exists = await pdfFileExists(filePath);
|
|
636
|
+
if (exists && state.pdfStatus !== 'downloaded') {
|
|
637
|
+
await this.setPdfState(task, paperId, { pdfStatus: 'downloaded', pdfFile: filePath });
|
|
638
|
+
}
|
|
639
|
+
else if (!exists && state.pdfStatus !== 'missing') {
|
|
640
|
+
await this.setPdfState(task, paperId, { pdfStatus: 'missing', pdfFile: filePath });
|
|
641
|
+
}
|
|
642
|
+
return { exists };
|
|
643
|
+
}
|
|
522
644
|
/** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
|
|
523
645
|
async downloadPdf(id, paperId, workspace) {
|
|
524
646
|
const task = await this.get(id);
|
|
@@ -539,6 +661,7 @@ export class TaskService {
|
|
|
539
661
|
status: fallback,
|
|
540
662
|
bytes: 0,
|
|
541
663
|
error: resolution.error ?? '下载失败',
|
|
664
|
+
...(resolution.reason === undefined ? {} : { reason: resolution.reason }),
|
|
542
665
|
};
|
|
543
666
|
return result;
|
|
544
667
|
}
|
|
@@ -558,23 +681,6 @@ export class TaskService {
|
|
|
558
681
|
result.url = resolution.url;
|
|
559
682
|
return result;
|
|
560
683
|
}
|
|
561
|
-
/** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
|
|
562
|
-
async previewPdf(id, paperId, workspace) {
|
|
563
|
-
const task = await this.get(id);
|
|
564
|
-
const paper = findPaper(task, paperId);
|
|
565
|
-
const filename = readableFilename(paper);
|
|
566
|
-
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
567
|
-
const recorded = task.states[paperId]?.pdfFile;
|
|
568
|
-
const saved = recorded === undefined ? null : await readSavedPdf(recorded);
|
|
569
|
-
const local = saved ?? (await readLocalPdf(paper, target));
|
|
570
|
-
if (local !== null)
|
|
571
|
-
return { buffer: local, filename };
|
|
572
|
-
const resolution = await resolvePdf(paper);
|
|
573
|
-
if (!resolution.ok || resolution.buffer === undefined) {
|
|
574
|
-
throw new SearchError('not_found', resolution.error ?? '没有可预览的 PDF');
|
|
575
|
-
}
|
|
576
|
-
return { buffer: resolution.buffer, filename };
|
|
577
|
-
}
|
|
578
684
|
/** Europe PMC 全文(JATS → 只含安全标签的 HTML)。 */
|
|
579
685
|
async fullText(id, paperId) {
|
|
580
686
|
const task = await this.get(id);
|
|
@@ -596,15 +702,19 @@ export class TaskService {
|
|
|
596
702
|
return html;
|
|
597
703
|
}
|
|
598
704
|
/** 单篇论文详情:独立论文页使用,含任务标题与分组。 */
|
|
599
|
-
async paperDetail(id, paperId) {
|
|
705
|
+
async paperDetail(id, paperId, workspace) {
|
|
600
706
|
const task = await this.get(id);
|
|
707
|
+
if (await reconcilePdfFiles(task))
|
|
708
|
+
await this.store.save(task);
|
|
601
709
|
const paper = findPaper(task, paperId);
|
|
710
|
+
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
602
711
|
const detail = {
|
|
603
712
|
taskId: task.id,
|
|
604
713
|
taskTitle: task.title,
|
|
605
714
|
rev: task.rev,
|
|
606
715
|
paper,
|
|
607
716
|
groups: task.groups,
|
|
717
|
+
downloadDir: resolvePdfRoot(target),
|
|
608
718
|
};
|
|
609
719
|
const state = task.states[paperId];
|
|
610
720
|
if (state !== undefined)
|
|
@@ -634,13 +744,23 @@ export class TaskService {
|
|
|
634
744
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
635
745
|
}
|
|
636
746
|
}
|
|
637
|
-
/**
|
|
638
|
-
async
|
|
747
|
+
/** 可写入的 Zotero 库与分类,供同步弹窗选择目标目录。 */
|
|
748
|
+
async zoteroTargets() {
|
|
749
|
+
const settings = getSettings();
|
|
750
|
+
try {
|
|
751
|
+
return await new ZoteroConnector(settings.zoteroBaseUrl).listTargets();
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
return { ok: false, targets: [], error: error instanceof Error ? error.message : String(error) };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/** 把选中论文同步到 Zotero(含本地 PDF 附件),并记录每篇的同步状态。 */
|
|
758
|
+
async zoteroSync(id, paperIds, workspace, target) {
|
|
639
759
|
const settings = getSettings();
|
|
640
760
|
if (!settings.zoteroEnabled)
|
|
641
761
|
throw new SearchError('bad_request', 'Zotero 同步已在设置中关闭');
|
|
642
762
|
const task = await this.get(id);
|
|
643
|
-
const
|
|
763
|
+
const workspaceDir = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
644
764
|
const wanted = paperIds === undefined || paperIds.length === 0
|
|
645
765
|
? task.result.papers.slice(0, 50)
|
|
646
766
|
: paperIds.map((paperId) => findPaper(task, paperId));
|
|
@@ -651,14 +771,18 @@ export class TaskService {
|
|
|
651
771
|
if (!probe.ok) {
|
|
652
772
|
throw new SearchError('network_error', `Zotero 未运行或 connector 不可达:${probe.error ?? settings.zoteroBaseUrl}`);
|
|
653
773
|
}
|
|
654
|
-
|
|
774
|
+
// 目标目录优先:用户选定的分类可能是另一个库,不能只看当前选中的分类。
|
|
775
|
+
const filesEditable = target?.filesEditable ?? probe.collection?.filesEditable;
|
|
776
|
+
const readOnlyAttachments = settings.zoteroAttachPdf && filesEditable === false;
|
|
655
777
|
const attachPdf = settings.zoteroAttachPdf && !readOnlyAttachments;
|
|
656
778
|
const inputs = [];
|
|
657
779
|
for (const paper of wanted) {
|
|
658
780
|
const state = task.states[paper.paperId];
|
|
659
|
-
|
|
781
|
+
// 标签已移除,用任务内的分组名承载 Zotero 侧的分类信息。
|
|
782
|
+
const group = task.groups.find((entry) => entry.id === state?.groupId);
|
|
783
|
+
const tags = group === undefined ? [] : [group.name];
|
|
660
784
|
const pdfPath = attachPdf
|
|
661
|
-
? await this.ensureLocalPdf(paper,
|
|
785
|
+
? await this.ensureLocalPdf(paper, workspaceDir, state?.pdfFile)
|
|
662
786
|
: undefined;
|
|
663
787
|
inputs.push({ paper, tags, ...(pdfPath === undefined ? {} : { pdfPath }) });
|
|
664
788
|
}
|
|
@@ -667,12 +791,31 @@ export class TaskService {
|
|
|
667
791
|
baseUrl: settings.zoteroBaseUrl,
|
|
668
792
|
attachPdf,
|
|
669
793
|
tag: settings.zoteroTag,
|
|
794
|
+
...(target === undefined
|
|
795
|
+
? {}
|
|
796
|
+
: {
|
|
797
|
+
targetId: target.id,
|
|
798
|
+
...(target.name === undefined ? {} : { targetName: target.name }),
|
|
799
|
+
}),
|
|
670
800
|
});
|
|
671
|
-
if (probe.collection === undefined) {
|
|
801
|
+
if (target === undefined && probe.collection === undefined) {
|
|
672
802
|
result.messages.unshift('Zotero 当前未选择分类,条目已保存到「我的文库」');
|
|
673
803
|
}
|
|
674
804
|
else if (readOnlyAttachments) {
|
|
675
|
-
|
|
805
|
+
const label = target === undefined ? '当前分类' : `「${target.name ?? target.id}」`;
|
|
806
|
+
result.messages.unshift(`${label}不可写附件,已只保存条目;可换一个可写目录后重试`);
|
|
807
|
+
}
|
|
808
|
+
const collectionName = target === undefined ? probe.collection?.name : target.name;
|
|
809
|
+
for (const item of result.items) {
|
|
810
|
+
await this.setZoteroState(task, item.paperId, item.ok
|
|
811
|
+
? {
|
|
812
|
+
zoteroStatus: 'synced',
|
|
813
|
+
...(collectionName === undefined ? {} : { zoteroCollection: collectionName }),
|
|
814
|
+
}
|
|
815
|
+
: {
|
|
816
|
+
zoteroStatus: 'failed',
|
|
817
|
+
zoteroError: item.error ?? result.messages[0] ?? '同步失败',
|
|
818
|
+
});
|
|
676
819
|
}
|
|
677
820
|
return result;
|
|
678
821
|
}
|
|
@@ -680,6 +823,26 @@ export class TaskService {
|
|
|
680
823
|
throw new SearchError('upstream_error', error instanceof Error ? error.message : String(error));
|
|
681
824
|
}
|
|
682
825
|
}
|
|
826
|
+
/** 记录单篇文献的 Zotero 同步状态;与 PDF 状态一样写回任务并广播。 */
|
|
827
|
+
async setZoteroState(task, paperId, patch) {
|
|
828
|
+
if (this.store.summary(task.id) === undefined)
|
|
829
|
+
return;
|
|
830
|
+
const current = task.states[paperId] ?? emptyState();
|
|
831
|
+
const next = { ...current, zoteroStatus: patch.zoteroStatus };
|
|
832
|
+
if (patch.zoteroCollection === undefined)
|
|
833
|
+
delete next.zoteroCollection;
|
|
834
|
+
else
|
|
835
|
+
next.zoteroCollection = patch.zoteroCollection.slice(0, 200);
|
|
836
|
+
if (patch.zoteroError === undefined)
|
|
837
|
+
delete next.zoteroError;
|
|
838
|
+
else
|
|
839
|
+
next.zoteroError = patch.zoteroError.slice(0, 300);
|
|
840
|
+
task.states[paperId] = next;
|
|
841
|
+
task.rev += 1;
|
|
842
|
+
task.updatedAt = new Date().toISOString();
|
|
843
|
+
await this.store.save(task);
|
|
844
|
+
this.events.publish(toSummary(task));
|
|
845
|
+
}
|
|
683
846
|
/** 下载目录可写性测试(设置页)。 */
|
|
684
847
|
async storageProbe(workspace) {
|
|
685
848
|
const outcome = await probePdfRoot(workspace);
|
|
@@ -721,6 +884,10 @@ export class TaskService {
|
|
|
721
884
|
delete next.pdfFile;
|
|
722
885
|
else
|
|
723
886
|
next.pdfFile = patch.pdfFile.slice(0, 1024);
|
|
887
|
+
// 刚写入的文件立即视为存在,避免存在性缓存把状态又翻回 `missing`。
|
|
888
|
+
if (patch.pdfStatus === 'downloaded' && next.pdfFile !== undefined) {
|
|
889
|
+
pdfExistsCache.set(next.pdfFile, { exists: true, at: Date.now() });
|
|
890
|
+
}
|
|
724
891
|
task.states[paperId] = next;
|
|
725
892
|
task.rev += 1;
|
|
726
893
|
task.updatedAt = new Date().toISOString();
|
|
@@ -779,6 +946,13 @@ export class TaskService {
|
|
|
779
946
|
return undefined;
|
|
780
947
|
return { paperIds: new Set(base.result.papers.map((paper) => paper.paperId)), rev: base.rev };
|
|
781
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* 供模型下载工具复用:`newOnly` 需要基准任务的论文集合才能与界面口径一致。
|
|
951
|
+
* 直接暴露给工具层,避免下载范围解析和 `/results` 出现两套过滤逻辑。
|
|
952
|
+
*/
|
|
953
|
+
async resolveDownloadFilter(id, filter) {
|
|
954
|
+
return this.withBaseFilter(await this.get(id), filter);
|
|
955
|
+
}
|
|
782
956
|
/** `newOnly` 需要基准任务的论文集合;基准已删除时忽略该条件。 */
|
|
783
957
|
async withBaseFilter(task, filter) {
|
|
784
958
|
if (filter.newOnly !== true || task.fromTaskId === undefined)
|
|
@@ -970,14 +1144,8 @@ function readTitle(title, conditions) {
|
|
|
970
1144
|
function inheritStates(base) {
|
|
971
1145
|
const states = {};
|
|
972
1146
|
for (const [paperId, state] of Object.entries(base.states)) {
|
|
973
|
-
//
|
|
1147
|
+
// 分组是任务内概念,继承时只带筛读状态。
|
|
974
1148
|
const next = { screening: state.screening };
|
|
975
|
-
if (state.shortlistOrder !== undefined)
|
|
976
|
-
next.shortlistOrder = state.shortlistOrder;
|
|
977
|
-
if (state.note !== undefined)
|
|
978
|
-
next.note = state.note;
|
|
979
|
-
if (state.tags !== undefined)
|
|
980
|
-
next.tags = [...state.tags];
|
|
981
1149
|
states[paperId] = next;
|
|
982
1150
|
}
|
|
983
1151
|
return states;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 筛读台账:论文级状态(四态筛读 /
|
|
2
|
+
* 筛读台账:论文级状态(四态筛读 / 分组)与任务分组。
|
|
3
3
|
*
|
|
4
4
|
* 所有写入口(面板注解、模型 `literature_task annotate`、分组编辑)都先经过
|
|
5
5
|
* 这里的校验,确保状态结构稳定;`states` 以稳定的 `paperId` 为键,因此跨任务
|
|
@@ -24,5 +24,3 @@ export declare function applyAnnotations(task: SearchTask, inputs: readonly Anno
|
|
|
24
24
|
export declare function normalizeGroups(values: readonly TaskGroup[] | undefined): TaskGroup[] | undefined;
|
|
25
25
|
/** 分组若仍有论文引用,删除前提醒调用方(调用方决定是否允许)。 */
|
|
26
26
|
export declare function groupUsage(task: SearchTask, groupId: string): number;
|
|
27
|
-
/** 按必读顺序、年份、标题排序某组的论文。 */
|
|
28
|
-
export declare function sortGroupPapers(task: SearchTask, papers: readonly Paper[]): Paper[];
|
package/lib/host/tasks/states.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 筛读台账:论文级状态(四态筛读 /
|
|
2
|
+
* 筛读台账:论文级状态(四态筛读 / 分组)与任务分组。
|
|
3
3
|
*
|
|
4
4
|
* 所有写入口(面板注解、模型 `literature_task annotate`、分组编辑)都先经过
|
|
5
5
|
* 这里的校验,确保状态结构稳定;`states` 以稳定的 `paperId` 为键,因此跨任务
|
|
@@ -10,17 +10,14 @@ const MAX_ANNOTATIONS = 50;
|
|
|
10
10
|
const MAX_GROUPS = 50;
|
|
11
11
|
const MAX_GROUP_NAME = 60;
|
|
12
12
|
const MAX_GROUP_DESCRIPTION = 500;
|
|
13
|
-
const MAX_NOTE = 4000;
|
|
14
|
-
const MAX_TAGS = 20;
|
|
15
|
-
const MAX_TAG = 40;
|
|
16
13
|
const MAX_PDF_ERROR = 300;
|
|
17
14
|
const MAX_PDF_FILE = 1024;
|
|
18
|
-
const MAX_SHORTLIST_ORDER = 100_000;
|
|
19
15
|
export const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
|
|
20
16
|
export const PDF_STATUSES = [
|
|
21
17
|
'unknown',
|
|
22
18
|
'available',
|
|
23
19
|
'downloaded',
|
|
20
|
+
'missing',
|
|
24
21
|
'html-only',
|
|
25
22
|
'paywalled',
|
|
26
23
|
'blocked',
|
|
@@ -86,40 +83,6 @@ export function applyAnnotations(task, inputs) {
|
|
|
86
83
|
}
|
|
87
84
|
touched = true;
|
|
88
85
|
}
|
|
89
|
-
if (input.shortlistOrder !== undefined) {
|
|
90
|
-
if (input.shortlistOrder === null) {
|
|
91
|
-
delete next.shortlistOrder;
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
if (!Number.isInteger(input.shortlistOrder) ||
|
|
95
|
-
input.shortlistOrder < 0 ||
|
|
96
|
-
input.shortlistOrder > MAX_SHORTLIST_ORDER) {
|
|
97
|
-
throw new SearchError('bad_request', `shortlistOrder 必须是 0..${MAX_SHORTLIST_ORDER} 的整数`);
|
|
98
|
-
}
|
|
99
|
-
next.shortlistOrder = input.shortlistOrder;
|
|
100
|
-
}
|
|
101
|
-
touched = true;
|
|
102
|
-
}
|
|
103
|
-
if (input.note !== undefined) {
|
|
104
|
-
if (typeof input.note !== 'string')
|
|
105
|
-
throw new SearchError('bad_request', 'note 必须是字符串');
|
|
106
|
-
const note = input.note.trim();
|
|
107
|
-
if (note === '')
|
|
108
|
-
delete next.note;
|
|
109
|
-
else
|
|
110
|
-
next.note = note.slice(0, MAX_NOTE);
|
|
111
|
-
touched = true;
|
|
112
|
-
}
|
|
113
|
-
if (input.tags !== undefined) {
|
|
114
|
-
if (!Array.isArray(input.tags))
|
|
115
|
-
throw new SearchError('bad_request', 'tags 必须是字符串数组');
|
|
116
|
-
const tags = normalizeTags(input.tags);
|
|
117
|
-
if (tags.length === 0)
|
|
118
|
-
delete next.tags;
|
|
119
|
-
else
|
|
120
|
-
next.tags = tags;
|
|
121
|
-
touched = true;
|
|
122
|
-
}
|
|
123
86
|
if (input.pdfStatus !== undefined) {
|
|
124
87
|
if (!isPdfStatus(input.pdfStatus)) {
|
|
125
88
|
throw new SearchError('bad_request', `pdfStatus 只能是 ${PDF_STATUSES.join(' / ')}`);
|
|
@@ -194,30 +157,3 @@ export function normalizeGroups(values) {
|
|
|
194
157
|
export function groupUsage(task, groupId) {
|
|
195
158
|
return Object.values(task.states).filter((state) => state.groupId === groupId).length;
|
|
196
159
|
}
|
|
197
|
-
/** 按必读顺序、年份、标题排序某组的论文。 */
|
|
198
|
-
export function sortGroupPapers(task, papers) {
|
|
199
|
-
return [...papers].sort((a, b) => {
|
|
200
|
-
const left = stateOf(task, a.paperId).shortlistOrder;
|
|
201
|
-
const right = stateOf(task, b.paperId).shortlistOrder;
|
|
202
|
-
if (left !== undefined || right !== undefined) {
|
|
203
|
-
if (left === undefined)
|
|
204
|
-
return 1;
|
|
205
|
-
if (right === undefined)
|
|
206
|
-
return -1;
|
|
207
|
-
if (left !== right)
|
|
208
|
-
return left - right;
|
|
209
|
-
}
|
|
210
|
-
return (b.year ?? 0) - (a.year ?? 0) || a.title.localeCompare(b.title);
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
function normalizeTags(values) {
|
|
214
|
-
const seen = new Set();
|
|
215
|
-
for (const raw of values) {
|
|
216
|
-
const tag = raw.trim().slice(0, MAX_TAG);
|
|
217
|
-
if (tag !== '')
|
|
218
|
-
seen.add(tag);
|
|
219
|
-
if (seen.size >= MAX_TAGS)
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
return [...seen];
|
|
223
|
-
}
|
package/lib/host/tasks/stats.js
CHANGED
|
@@ -72,6 +72,7 @@ function countFullText(task, papers) {
|
|
|
72
72
|
unknown: 0,
|
|
73
73
|
available: 0,
|
|
74
74
|
downloaded: 0,
|
|
75
|
+
missing: 0,
|
|
75
76
|
htmlOnly: 0,
|
|
76
77
|
paywalled: 0,
|
|
77
78
|
blocked: 0,
|
|
@@ -85,6 +86,8 @@ function countFullText(task, papers) {
|
|
|
85
86
|
coverage.available += 1;
|
|
86
87
|
else if (status === 'downloaded')
|
|
87
88
|
coverage.downloaded += 1;
|
|
89
|
+
else if (status === 'missing')
|
|
90
|
+
coverage.missing += 1;
|
|
88
91
|
else if (status === 'paywalled')
|
|
89
92
|
coverage.paywalled += 1;
|
|
90
93
|
else if (status === 'blocked')
|
|
@@ -27,7 +27,7 @@ export function toSummary(task) {
|
|
|
27
27
|
export function toDetail(task) {
|
|
28
28
|
const states = {};
|
|
29
29
|
for (const [paperId, state] of Object.entries(task.states)) {
|
|
30
|
-
states[paperId] = { ...state
|
|
30
|
+
states[paperId] = { ...state };
|
|
31
31
|
}
|
|
32
32
|
return {
|
|
33
33
|
...toSummary(task),
|
package/lib/host/tools.d.ts
CHANGED
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
|
|
7
7
|
* 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
|
|
8
8
|
* - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
|
|
9
|
-
* - `literature_task
|
|
9
|
+
* - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读与分组,
|
|
10
10
|
* 做新老对比与统计。
|
|
11
11
|
* - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
|
|
12
|
+
* - `literature_download`:把自然语言下载请求解析成已校验的 paperId 清单,
|
|
13
|
+
* 通过事件下发给右侧栏,由右侧栏复用批量下载弹窗执行并逐篇展示。
|
|
14
|
+
* - `literature_zotero`:与下载共用范围解析,改为让右侧栏复用 Zotero 同步
|
|
15
|
+
* 弹窗;对话里点名目录且匹配成功才自动开跑,否则由用户选目录。
|
|
12
16
|
*
|
|
13
17
|
* 参数在边界做一次校验(模型输出不可信),服务层仍会再兜底。
|
|
14
18
|
*/
|