dsh-plugin-litsearch 0.2.0 → 0.3.5

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 (47) hide show
  1. package/README.md +80 -38
  2. package/client/client.js +4750 -2342
  3. package/lib/host/export/bibtex.js +1 -4
  4. package/lib/host/export/common.d.ts +0 -2
  5. package/lib/host/export/common.js +0 -7
  6. package/lib/host/export/csv.js +1 -5
  7. package/lib/host/export/markdown.d.ts +1 -1
  8. package/lib/host/export/markdown.js +9 -26
  9. package/lib/host/export/ris.js +1 -4
  10. package/lib/host/paths.d.ts +4 -3
  11. package/lib/host/paths.js +4 -3
  12. package/lib/host/pdf/root.d.ts +11 -0
  13. package/lib/host/pdf/root.js +27 -0
  14. package/lib/host/pdf/store.d.ts +17 -7
  15. package/lib/host/pdf/store.js +110 -32
  16. package/lib/host/router.js +101 -87
  17. package/lib/host/tasks/filter.d.ts +3 -1
  18. package/lib/host/tasks/filter.js +45 -4
  19. package/lib/host/tasks/runner.js +1 -8
  20. package/lib/host/tasks/service.d.ts +21 -13
  21. package/lib/host/tasks/service.js +158 -47
  22. package/lib/host/tasks/states.d.ts +1 -3
  23. package/lib/host/tasks/states.js +2 -66
  24. package/lib/host/tasks/stats.js +9 -4
  25. package/lib/host/tasks/store.d.ts +1 -1
  26. package/lib/host/tasks/store.js +1 -1
  27. package/lib/host/tasks/summary.js +1 -1
  28. package/lib/host/tools.d.ts +1 -1
  29. package/lib/host/tools.js +25 -38
  30. package/lib/host/workspace.d.ts +20 -0
  31. package/lib/host/workspace.js +32 -0
  32. package/lib/index.d.ts +17 -8
  33. package/lib/index.js +33 -11
  34. package/lib/search/aggregate.js +1 -0
  35. package/lib/search/config.d.ts +17 -1
  36. package/lib/search/config.js +16 -1
  37. package/lib/search/identity.js +1 -0
  38. package/lib/search/oa.d.ts +8 -0
  39. package/lib/search/oa.js +53 -6
  40. package/lib/search/providers/googlescholar.d.ts +12 -0
  41. package/lib/search/providers/googlescholar.js +154 -0
  42. package/lib/search/providers/index.js +2 -0
  43. package/lib/search/query-adapter.js +3 -0
  44. package/lib/search/rate-limit.js +2 -1
  45. package/lib/search/types.d.ts +35 -13
  46. package/lib/search/types.js +25 -1
  47. package/package.json +1 -1
@@ -6,19 +6,26 @@
6
6
  * `sec-fetch-site: cross-site`。写端点额外要求自定义头 + JSON content-type,
7
7
  * 两者一起让浏览器必须预检,堵掉跨站表单与简单请求。
8
8
  */
9
- import { MAX_LIMIT, ROUTE_PREFIX, WRITE_HEADER } from '../search/config.js';
9
+ import { spawn } from 'node:child_process';
10
+ import { mkdir, stat } from 'node:fs/promises';
11
+ import { dirname, extname, isAbsolute, resolve } from 'node:path';
12
+ import { MAX_LIMIT, ROUTE_PREFIX, SESSION_HEADER, WRITE_HEADER } from '../search/config.js';
10
13
  import { SearchError } from '../search/errors.js';
11
14
  import { asRecord } from '../search/normalize.js';
12
15
  import { isSourceId } from '../search/providers/index.js';
16
+ import { TASK_LANGUAGES } from '../search/types.js';
17
+ import { resolvePdfRoot } from './pdf/store.js';
18
+ import { resolveSessionWorkspace } from './workspace.js';
13
19
  const MAX_BODY_BYTES = 1_000_000;
14
20
  const SORT_MODES = ['relevance', 'citations', 'year'];
15
- const LANGUAGES = ['any', 'eng', 'chi'];
21
+ const LANGUAGES = TASK_LANGUAGES;
16
22
  const ORIGINS = ['model', 'panel', 'history', 'api'];
17
23
  const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
18
24
  const PDF_STATUSES = [
19
25
  'unknown',
20
26
  'available',
21
27
  'downloaded',
28
+ 'missing',
22
29
  'html-only',
23
30
  'paywalled',
24
31
  'blocked',
@@ -39,6 +46,13 @@ function isTrusted(req) {
39
46
  return false;
40
47
  return isLoopbackHost(req.headers.host);
41
48
  }
49
+ /** 面板把当前会话 id 放在请求头里;iframe 预览等无法带头部的 GET 用查询参数。 */
50
+ function readSessionId(req, params) {
51
+ const header = req.headers[SESSION_HEADER];
52
+ const raw = typeof header === 'string' && header.trim() !== '' ? header : params.get('session') ?? '';
53
+ const id = raw.trim();
54
+ return id === '' ? undefined : id;
55
+ }
42
56
  /** 写端点要求自定义头 + JSON:跨站请求无法在不触发预检的情况下伪造。 */
43
57
  function hasWriteIntent(req) {
44
58
  if (req.headers[WRITE_HEADER] !== '1')
@@ -72,48 +86,6 @@ function sendFile(res, file) {
72
86
  });
73
87
  res.end(body);
74
88
  }
75
- /** 内联 PDF,支持单段 Range(预览器会按需拉取)。 */
76
- function sendPdf(req, res, buffer, filename) {
77
- const total = buffer.length;
78
- const disposition = `inline; filename*=UTF-8''${encodeURIComponent(filename)}`;
79
- const range = req.headers.range;
80
- if (range === undefined) {
81
- res.writeHead(200, {
82
- 'content-type': 'application/pdf',
83
- 'content-length': total,
84
- 'content-disposition': disposition,
85
- 'accept-ranges': 'bytes',
86
- 'cache-control': 'no-store',
87
- });
88
- res.end(buffer);
89
- return;
90
- }
91
- const match = /^bytes=(\d*)-(\d*)$/u.exec(range.trim());
92
- const start = match?.[1] === '' ? Number.NaN : Number(match?.[1]);
93
- const end = match?.[2] === '' ? Number.NaN : Number(match?.[2]);
94
- if (match === null || (!Number.isFinite(start) && !Number.isFinite(end))) {
95
- res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
96
- res.end();
97
- return;
98
- }
99
- const from = Number.isFinite(start) ? Math.max(0, start) : Math.max(0, total - (end || 0));
100
- const to = Number.isFinite(end) ? Math.min(end, total - 1) : total - 1;
101
- if (from > to || from >= total) {
102
- res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
103
- res.end();
104
- return;
105
- }
106
- const chunk = buffer.subarray(from, to + 1);
107
- res.writeHead(206, {
108
- 'content-type': 'application/pdf',
109
- 'content-length': chunk.length,
110
- 'content-range': `bytes ${from}-${to}/${total}`,
111
- 'content-disposition': disposition,
112
- 'accept-ranges': 'bytes',
113
- 'cache-control': 'no-store',
114
- });
115
- res.end(chunk);
116
- }
117
89
  function sendHtml(res, status, html) {
118
90
  res.writeHead(status, {
119
91
  'content-type': 'text/html; charset=utf-8',
@@ -121,6 +93,52 @@ function sendHtml(res, status, html) {
121
93
  });
122
94
  res.end(html);
123
95
  }
96
+ /**
97
+ * 在系统文件管理器中定位文件:macOS Finder、Windows 资源管理器、Linux 桌面。
98
+ * 只接受已存在的 PDF 文件或目录(批量下载的「打开下载目录」用后者),
99
+ * 避免这个端点被当成任意路径探测。
100
+ */
101
+ async function revealInFileManager(rawPath, downloadRoot) {
102
+ const target = resolve(rawPath);
103
+ let info = await stat(target).catch(() => null);
104
+ // 下载目录可能一篇都还没下过:打开前补建,避免点「打开下载目录」毫无反应。
105
+ if (info === null && downloadRoot !== undefined && resolve(downloadRoot) === target) {
106
+ await mkdir(target, { recursive: true });
107
+ info = await stat(target).catch(() => null);
108
+ }
109
+ if (info === null)
110
+ throw new SearchError('not_found', '文件不存在或已被移动');
111
+ if (info.isDirectory()) {
112
+ const [command, args] = process.platform === 'darwin'
113
+ ? ['open', [target]]
114
+ : process.platform === 'win32'
115
+ ? ['explorer.exe', [target]]
116
+ : ['xdg-open', [target]];
117
+ await spawnDetached(command, args);
118
+ return { path: target };
119
+ }
120
+ if (!info.isFile() || extname(target).toLowerCase() !== '.pdf') {
121
+ throw new SearchError('bad_request', '只能定位 PDF 文件或下载目录');
122
+ }
123
+ const [command, args] = process.platform === 'darwin'
124
+ ? ['open', ['-R', target]]
125
+ : process.platform === 'win32'
126
+ ? ['explorer.exe', [`/select,${target}`]]
127
+ : ['xdg-open', [dirname(target)]];
128
+ await spawnDetached(command, args);
129
+ return { path: target };
130
+ }
131
+ /** 启动系统命令后立即脱离,不等待它的生命周期。 */
132
+ async function spawnDetached(command, args) {
133
+ await new Promise((settle, rejectSpawn) => {
134
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
135
+ child.once('error', rejectSpawn);
136
+ child.once('spawn', () => {
137
+ child.unref();
138
+ settle();
139
+ });
140
+ });
141
+ }
124
142
  function sendHtmlError(res, error) {
125
143
  const { status, error: apiError } = toApiError(error);
126
144
  sendHtml(res, status === 500 ? 502 : status, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>全文不可用</title></head><body style="font:14px/1.7 -apple-system,'PingFang SC',sans-serif;padding:16px"><p>全文不可用:${escapeHtml(apiError.message)}</p></body></html>`);
@@ -291,26 +309,6 @@ function readAnnotations(value) {
291
309
  if (groupId !== undefined)
292
310
  input.groupId = groupId;
293
311
  }
294
- if (record['shortlistOrder'] === null)
295
- input.shortlistOrder = null;
296
- else if (record['shortlistOrder'] !== undefined) {
297
- const order = readInt(record['shortlistOrder'], 'shortlistOrder', 0, 100_000);
298
- if (order !== undefined)
299
- input.shortlistOrder = order;
300
- }
301
- if (record['note'] !== undefined) {
302
- if (typeof record['note'] !== 'string') {
303
- throw new SearchError('bad_request', 'note 必须是字符串');
304
- }
305
- input.note = record['note'].slice(0, 4_000);
306
- }
307
- if (record['tags'] !== undefined) {
308
- const tags = readStringArray(record['tags'], 'tags', 20, 40);
309
- if (tags !== undefined && tags !== null)
310
- input.tags = tags;
311
- else if (tags === null)
312
- input.tags = [];
313
- }
314
312
  return input;
315
313
  });
316
314
  }
@@ -561,7 +559,7 @@ function parseSegments(pathname) {
561
559
  }
562
560
  });
563
561
  }
564
- async function handleTasks(service, req, res, seg, params) {
562
+ async function handleTasks(service, req, res, seg, params, sessionId) {
565
563
  const id = seg[1];
566
564
  if (id === undefined) {
567
565
  if (req.method === 'GET') {
@@ -570,7 +568,8 @@ async function handleTasks(service, req, res, seg, params) {
570
568
  }
571
569
  if (req.method === 'POST') {
572
570
  const input = readCreateInput(await readJsonBody(req));
573
- const task = await service.create({ ...input, origin: 'panel' });
571
+ const workspace = await resolveSessionWorkspace(sessionId);
572
+ const task = await service.create({ ...input, origin: 'panel', workspace });
574
573
  ok(res, await service.getDetail(task.id), 201);
575
574
  return;
576
575
  }
@@ -580,7 +579,7 @@ async function handleTasks(service, req, res, seg, params) {
580
579
  const action = seg[2];
581
580
  if (action === undefined) {
582
581
  if (req.method === 'GET') {
583
- ok(res, await service.getDetail(id));
582
+ ok(res, await service.getDetail(id, await resolveSessionWorkspace(sessionId)));
584
583
  return;
585
584
  }
586
585
  if (req.method === 'PATCH') {
@@ -629,7 +628,7 @@ async function handleTasks(service, req, res, seg, params) {
629
628
  fail(res, 405, 'bad_request', 'activate 仅支持 POST');
630
629
  return;
631
630
  }
632
- const task = await service.activate(id);
631
+ const task = await service.activate(id, await resolveSessionWorkspace(sessionId));
633
632
  ok(res, await service.getDetail(task.id));
634
633
  return;
635
634
  }
@@ -654,7 +653,7 @@ async function handleTasks(service, req, res, seg, params) {
654
653
  fail(res, 405, 'bad_request', '论文详情仅支持 GET');
655
654
  return;
656
655
  }
657
- ok(res, await service.paperDetail(id, paperId));
656
+ ok(res, await service.paperDetail(id, paperId, await resolveSessionWorkspace(sessionId)));
658
657
  return;
659
658
  }
660
659
  if (op === 'entities') {
@@ -665,27 +664,20 @@ async function handleTasks(service, req, res, seg, params) {
665
664
  ok(res, await service.entities(id, paperId));
666
665
  return;
667
666
  }
668
- if (op === 'download') {
667
+ if (op === 'recheck') {
669
668
  if (req.method !== 'POST') {
670
- fail(res, 405, 'bad_request', 'download 仅支持 POST');
669
+ fail(res, 405, 'bad_request', 'recheck 仅支持 POST');
671
670
  return;
672
671
  }
673
- ok(res, await service.downloadPdf(id, paperId));
672
+ ok(res, await service.recheckPdf(id, paperId));
674
673
  return;
675
674
  }
676
- if (op === 'pdf') {
677
- if (req.method !== 'GET') {
678
- fail(res, 405, 'bad_request', 'pdf 仅支持 GET');
675
+ if (op === 'download') {
676
+ if (req.method !== 'POST') {
677
+ fail(res, 405, 'bad_request', 'download 仅支持 POST');
679
678
  return;
680
679
  }
681
- try {
682
- const file = await service.previewPdf(id, paperId);
683
- sendPdf(req, res, file.buffer, file.filename);
684
- }
685
- catch (error) {
686
- const { status, error: apiError } = toApiError(error);
687
- fail(res, status === 500 ? 502 : status, apiError.code, apiError.message);
688
- }
680
+ ok(res, await service.downloadPdf(id, paperId, await resolveSessionWorkspace(sessionId)));
689
681
  return;
690
682
  }
691
683
  if (op === 'fulltext') {
@@ -711,7 +703,7 @@ async function handleTasks(service, req, res, seg, params) {
711
703
  }
712
704
  const body = await readJsonBody(req);
713
705
  const ids = readStringArray(body['paperIds'], 'paperIds', 50, 80);
714
- ok(res, await service.zoteroSync(id, ids === undefined || ids === null ? undefined : ids));
706
+ ok(res, await service.zoteroSync(id, ids === undefined || ids === null ? undefined : ids, await resolveSessionWorkspace(sessionId)));
715
707
  return;
716
708
  }
717
709
  if (action === 'compare') {
@@ -737,7 +729,8 @@ async function handleTasks(service, req, res, seg, params) {
737
729
  throw new SearchError('conflict', '任务正在运行');
738
730
  // 面板里主动重跑 = 用户正在这个任务里工作,把它设为「当前任务」。
739
731
  service.setCurrentTask(id);
740
- void service.run(id, mode, 'panel').catch((error) => {
732
+ const workspace = await resolveSessionWorkspace(sessionId);
733
+ void service.run(id, mode, 'panel', workspace).catch((error) => {
741
734
  console.warn('[dsh-plugin-litsearch] 任务执行失败:', error);
742
735
  });
743
736
  ok(res, await service.getDetail(id), 202);
@@ -791,6 +784,26 @@ export function createHandler(service) {
791
784
  ok(res, await service.eventsSince(Number.isFinite(sinceRev) ? sinceRev : 0));
792
785
  return;
793
786
  }
787
+ if (head === 'files') {
788
+ if (seg[1] !== 'reveal') {
789
+ fail(res, 404, 'not_found', `未知文件操作 ${seg[1] ?? ''}`);
790
+ return;
791
+ }
792
+ if (req.method !== 'POST') {
793
+ fail(res, 405, 'bad_request', 'reveal 仅支持 POST');
794
+ return;
795
+ }
796
+ const body = await readJsonBody(req);
797
+ const path = readString(body['path'], 'path', 4096);
798
+ if (path === undefined || path === '') {
799
+ throw new SearchError('bad_request', '缺少 path 参数');
800
+ }
801
+ if (!isAbsolute(path))
802
+ throw new SearchError('bad_request', 'path 必须是绝对路径');
803
+ const workspace = await resolveSessionWorkspace(readSessionId(req, url.searchParams));
804
+ ok(res, await revealInFileManager(path, resolvePdfRoot(workspace)));
805
+ return;
806
+ }
794
807
  if (head === 'settings') {
795
808
  if (seg[1] === 'probe' && req.method === 'POST') {
796
809
  const source = readString((await readJsonBody(req))['source'], 'source', 40);
@@ -810,14 +823,15 @@ export function createHandler(service) {
810
823
  return;
811
824
  }
812
825
  if (seg[1] === 'storage' && req.method === 'POST') {
813
- ok(res, await service.storageProbe());
826
+ const workspace = await resolveSessionWorkspace(readSessionId(req, url.searchParams));
827
+ ok(res, await service.storageProbe(workspace));
814
828
  return;
815
829
  }
816
830
  fail(res, 404, 'not_found', `未知设置端点 ${seg[1] ?? ''}`);
817
831
  return;
818
832
  }
819
833
  if (head === 'tasks') {
820
- await handleTasks(service, req, res, seg, url.searchParams);
834
+ await handleTasks(service, req, res, seg, url.searchParams, readSessionId(req, url.searchParams));
821
835
  return;
822
836
  }
823
837
  if (head === 'export') {
@@ -37,6 +37,8 @@ export declare function filterPapers(task: SearchTask, filter?: PaperFilter): Pa
37
37
  export declare function screeningOf(task: SearchTask, paper: Paper): Screening;
38
38
  /** 有效全文状态:没有人工/下载记录时,上游给的 PDF 直链视为可用。 */
39
39
  export declare function pdfStatusOf(task: SearchTask, paper: Paper): PdfStatus;
40
- export declare function matchesLanguage(paper: Paper, language: TaskLanguage): boolean;
40
+ export declare function matchesLanguage(paper: {
41
+ language?: string;
42
+ }, language: TaskLanguage): boolean;
41
43
  /** 稳定的筛选指纹(统计缓存 key 的一部分)。 */
42
44
  export declare function filterKey(filter: PaperFilter): string;
@@ -72,11 +72,52 @@ export function pdfStatusOf(task, paper) {
72
72
  export function matchesLanguage(paper, language) {
73
73
  if (language === 'any')
74
74
  return true;
75
- const value = (paper.language ?? '').toLowerCase();
76
- if (language === 'chi')
77
- return value.startsWith('chi') || value.startsWith('zh');
78
- return value.startsWith('eng') || value.startsWith('en');
75
+ const raw = (paper.language ?? '').trim().toLowerCase();
76
+ // 兼容 `en-US` / `zh-CN` 这类带地区的写法,只取主语言码。
77
+ const code = LANGUAGE_ALIASES[raw] ?? LANGUAGE_ALIASES[raw.split(/[-_]/u)[0] ?? ''];
78
+ return code !== undefined && code === language;
79
79
  }
80
+ /**
81
+ * 上游语言标记 → 统一的 PubMed 三字母码。
82
+ *
83
+ * PubMed / Europe PMC 给三字母码,OpenAlex 等给两字母码,这里都收敛到同一套,
84
+ * 避免语言筛选把同一种语言判成两种。
85
+ */
86
+ const LANGUAGE_ALIASES = {
87
+ eng: 'eng',
88
+ en: 'eng',
89
+ english: 'eng',
90
+ chi: 'chi',
91
+ zho: 'chi',
92
+ zh: 'chi',
93
+ chinese: 'chi',
94
+ jpn: 'jpn',
95
+ ja: 'jpn',
96
+ japanese: 'jpn',
97
+ kor: 'kor',
98
+ ko: 'kor',
99
+ korean: 'kor',
100
+ fre: 'fre',
101
+ fra: 'fre',
102
+ fr: 'fre',
103
+ french: 'fre',
104
+ ger: 'ger',
105
+ deu: 'ger',
106
+ de: 'ger',
107
+ german: 'ger',
108
+ spa: 'spa',
109
+ es: 'spa',
110
+ spanish: 'spa',
111
+ rus: 'rus',
112
+ ru: 'rus',
113
+ russian: 'rus',
114
+ ita: 'ita',
115
+ it: 'ita',
116
+ italian: 'ita',
117
+ por: 'por',
118
+ pt: 'por',
119
+ portuguese: 'por',
120
+ };
80
121
  /** 稳定的筛选指纹(统计缓存 key 的一部分)。 */
81
122
  export function filterKey(filter) {
82
123
  return [
@@ -8,6 +8,7 @@
8
8
  import { DEFAULT_TASK_MAX_ROUNDS, MAX_LIMIT, getSettings } from '../../search/config.js';
9
9
  import { searchSources } from '../../search/aggregate.js';
10
10
  import { mergeHits } from '../../search/identity.js';
11
+ import { matchesLanguage } from './filter.js';
11
12
  import { finishRound, startRound } from './rounds.js';
12
13
  import { countScreening } from './states.js';
13
14
  class RunCanceled extends Error {
@@ -198,11 +199,3 @@ function matchesConditions(paper, conditions) {
198
199
  }
199
200
  return true;
200
201
  }
201
- function matchesLanguage(paper, language) {
202
- if (language === 'any')
203
- return true;
204
- const value = (paper.language ?? '').toLowerCase();
205
- if (language === 'chi')
206
- return value.startsWith('chi') || value.startsWith('zh');
207
- return value.startsWith('eng') || value.startsWith('en');
208
- }
@@ -26,6 +26,8 @@ export interface CreateTaskInput {
26
26
  sort?: SortMode;
27
27
  origin: TaskOrigin;
28
28
  fromTaskId?: string;
29
+ /** 发起方所在会话的工作目录;下载默认落在这里。 */
30
+ workspace?: string;
29
31
  runNow?: boolean;
30
32
  }
31
33
  export interface TaskPatch {
@@ -76,6 +78,8 @@ export declare class TaskService {
76
78
  * 又新建一个同名任务。
77
79
  */
78
80
  private restore;
81
+ /** 按设置的总量上限修剪 PDF 去重缓存;失败只告警,不阻断启动。 */
82
+ private trimPdfCache;
79
83
  /** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
80
84
  private bootstrapCurrentTask;
81
85
  get globalRev(): number;
@@ -85,7 +89,7 @@ export declare class TaskService {
85
89
  eventsSince(sinceRev: number): Promise<EventsPage>;
86
90
  list(filter?: TaskListFilter): Promise<TaskSummary[]>;
87
91
  get(id: string): Promise<SearchTask>;
88
- getDetail(id: string): Promise<TaskDetail>;
92
+ getDetail(id: string, workspace?: string): Promise<TaskDetail>;
89
93
  /** 任务内轮次历史(时间升序、深拷贝)。 */
90
94
  getRounds(id: string): Promise<SearchRound[]>;
91
95
  /**
@@ -101,7 +105,9 @@ export declare class TaskService {
101
105
  focusTask(id: string, title: string, sessionId?: string): void;
102
106
  private persistCurrent;
103
107
  /** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
104
- activate(id: string): Promise<SearchTask>;
108
+ activate(id: string, workspace?: string): Promise<SearchTask>;
109
+ /** 记下任务最近一次所在的工作目录;只改变下载落点,不改变 rev。 */
110
+ private applyWorkspace;
105
111
  /** 等待该任务当前这一轮结束;返回是否确实已空闲。 */
106
112
  waitUntilIdle(id: string, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
107
113
  create(input: CreateTaskInput): Promise<SearchTask>;
@@ -112,7 +118,7 @@ export declare class TaskService {
112
118
  *
113
119
  * 任务已缓存时同步登记运行态,调用方紧接着调 `waitUntilIdle` 也不会误判为空闲。
114
120
  */
115
- run(id: string, mode?: 'new' | 'more', origin?: TaskOrigin): Promise<SearchTask>;
121
+ run(id: string, mode?: 'new' | 'more', origin?: TaskOrigin, workspace?: string): Promise<SearchTask>;
116
122
  /**
117
123
  * 在同一任务内开新一轮:全量替换检索条件后执行,**不新建任务、不新开 tab**。
118
124
  *
@@ -123,7 +129,7 @@ export declare class TaskService {
123
129
  cancel(id: string): Promise<SearchTask>;
124
130
  results(id: string, filter?: ResultsFilter): Promise<ResultsPage>;
125
131
  stats(id: string, filter?: PaperFilter, scope?: 'task' | 'filtered'): Promise<TaskStatsReport>;
126
- /** 写入筛读 / 分组 / 必读 / 备注;返回新的 rev 与命中条数。 */
132
+ /** 写入筛读 / 分组;返回新的 rev 与命中条数。 */
127
133
  annotate(id: string, inputs: readonly AnnotationInput[], expectedRev?: number): Promise<AnnotationResult>;
128
134
  compare(id: string, withTaskId: string): Promise<CompareResult>;
129
135
  /** 生成导出内容;`paperIds` 为空表示按当前筛选导出全部。 */
@@ -136,25 +142,27 @@ export declare class TaskService {
136
142
  }>;
137
143
  /** 设置页清理:任务文件与 PDF 缓存。 */
138
144
  cleanup(scope: CleanupScope): Promise<CleanupResult>;
139
- /** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
140
- downloadPdf(id: string, paperId: string): Promise<PdfDownloadResult>;
141
- /** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
142
- previewPdf(id: string, paperId: string): Promise<{
143
- buffer: Buffer;
144
- filename: string;
145
+ /**
146
+ * 「查看已下载」前的存在性检查:页面加载后文件可能被删除或移走。
147
+ * 缺失时把状态改为 `missing` 并广播,所有面板随即显示「下载文件已删除」。
148
+ */
149
+ recheckPdf(id: string, paperId: string): Promise<{
150
+ exists: boolean;
145
151
  }>;
152
+ /** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
153
+ downloadPdf(id: string, paperId: string, workspace?: string): Promise<PdfDownloadResult>;
146
154
  /** Europe PMC 全文(JATS → 只含安全标签的 HTML)。 */
147
155
  fullText(id: string, paperId: string): Promise<string>;
148
156
  /** 单篇论文详情:独立论文页使用,含任务标题与分组。 */
149
- paperDetail(id: string, paperId: string): Promise<PaperDetail>;
157
+ paperDetail(id: string, paperId: string, workspace?: string): Promise<PaperDetail>;
150
158
  /** PubTator3 实体标注;无 PMID 时明确告知不可标注。 */
151
159
  entities(id: string, paperId: string, signal?: AbortSignal): Promise<PaperEntities>;
152
160
  /** 测试 Zotero connector 是否可达,并返回当前选中的分类。 */
153
161
  zoteroProbe(): Promise<ZoteroProbeResult>;
154
162
  /** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
155
- zoteroSync(id: string, paperIds?: readonly string[]): Promise<ZoteroSyncResult>;
163
+ zoteroSync(id: string, paperIds?: readonly string[], workspace?: string): Promise<ZoteroSyncResult>;
156
164
  /** 下载目录可写性测试(设置页)。 */
157
- storageProbe(): Promise<StorageProbeResult>;
165
+ storageProbe(workspace?: string): Promise<StorageProbeResult>;
158
166
  private ensureLocalPdf;
159
167
  private setPdfState;
160
168
  /** 单源诊断:用固定短查询测一次连通性与延迟(不受冷却影响)。 */