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.
Files changed (39) hide show
  1. package/README.md +158 -41
  2. package/client/client.js +2632 -926
  3. package/lib/host/events.d.ts +9 -1
  4. package/lib/host/events.js +50 -3
  5. package/lib/host/export/bibtex.js +1 -4
  6. package/lib/host/export/common.d.ts +0 -2
  7. package/lib/host/export/common.js +0 -7
  8. package/lib/host/export/csv.js +1 -5
  9. package/lib/host/export/markdown.d.ts +1 -1
  10. package/lib/host/export/markdown.js +9 -26
  11. package/lib/host/export/ris.js +1 -4
  12. package/lib/host/paths.d.ts +1 -1
  13. package/lib/host/paths.js +1 -1
  14. package/lib/host/pdf/store.d.ts +5 -3
  15. package/lib/host/pdf/store.js +50 -17
  16. package/lib/host/router.js +146 -79
  17. package/lib/host/tasks/download.d.ts +104 -0
  18. package/lib/host/tasks/download.js +274 -0
  19. package/lib/host/tasks/filter.d.ts +8 -1
  20. package/lib/host/tasks/filter.js +14 -0
  21. package/lib/host/tasks/service.d.ts +44 -11
  22. package/lib/host/tasks/service.js +216 -48
  23. package/lib/host/tasks/states.d.ts +1 -3
  24. package/lib/host/tasks/states.js +2 -66
  25. package/lib/host/tasks/stats.js +3 -0
  26. package/lib/host/tasks/summary.js +1 -1
  27. package/lib/host/tools.d.ts +5 -1
  28. package/lib/host/tools.js +813 -37
  29. package/lib/host/zotero/connector.d.ts +19 -10
  30. package/lib/host/zotero/connector.js +142 -39
  31. package/lib/host/zotero/match.d.ts +27 -0
  32. package/lib/host/zotero/match.js +59 -0
  33. package/lib/search/config.d.ts +0 -2
  34. package/lib/search/config.js +0 -2
  35. package/lib/search/oa.d.ts +8 -0
  36. package/lib/search/oa.js +53 -6
  37. package/lib/search/types.d.ts +154 -10
  38. package/lib/search/types.js +11 -0
  39. 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, PLUGIN_VERSION, getSettings } from '../../search/config.js';
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, readLocalPdf, readSavedPdf, readableFilename, resolvePdf, resolvePdfRoot, } from '../pdf/store.js';
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
- return toDetail(await this.get(id));
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
- const sort = filter.sort ?? task.conditions.sort;
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
- /** 写入筛读 / 分组 / 必读 / 备注;返回新的 rev 与命中条数。 */
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
- /** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
638
- async zoteroSync(id, paperIds, workspace) {
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 target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
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
- const readOnlyAttachments = settings.zoteroAttachPdf && probe.collection?.filesEditable === false;
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
- const tags = state?.tags ?? [];
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, target, state?.pdfFile)
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
- result.messages.unshift('当前分类不可写附件,已只保存条目;可在 Zotero 中换一个可写分类后重试');
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[];
@@ -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
- }
@@ -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, ...(state.tags === undefined ? {} : { tags: [...state.tags] }) };
30
+ states[paperId] = { ...state };
31
31
  }
32
32
  return {
33
33
  ...toSummary(task),
@@ -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
  */