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.
- package/README.md +80 -38
- package/client/client.js +4750 -2342
- 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 +4 -3
- package/lib/host/paths.js +4 -3
- package/lib/host/pdf/root.d.ts +11 -0
- package/lib/host/pdf/root.js +27 -0
- package/lib/host/pdf/store.d.ts +17 -7
- package/lib/host/pdf/store.js +110 -32
- package/lib/host/router.js +101 -87
- package/lib/host/tasks/filter.d.ts +3 -1
- package/lib/host/tasks/filter.js +45 -4
- package/lib/host/tasks/runner.js +1 -8
- package/lib/host/tasks/service.d.ts +21 -13
- package/lib/host/tasks/service.js +158 -47
- package/lib/host/tasks/states.d.ts +1 -3
- package/lib/host/tasks/states.js +2 -66
- package/lib/host/tasks/stats.js +9 -4
- package/lib/host/tasks/store.d.ts +1 -1
- package/lib/host/tasks/store.js +1 -1
- package/lib/host/tasks/summary.js +1 -1
- package/lib/host/tools.d.ts +1 -1
- package/lib/host/tools.js +25 -38
- package/lib/host/workspace.d.ts +20 -0
- package/lib/host/workspace.js +32 -0
- package/lib/index.d.ts +17 -8
- package/lib/index.js +33 -11
- package/lib/search/aggregate.js +1 -0
- package/lib/search/config.d.ts +17 -1
- package/lib/search/config.js +16 -1
- package/lib/search/identity.js +1 -0
- package/lib/search/oa.d.ts +8 -0
- package/lib/search/oa.js +53 -6
- package/lib/search/providers/googlescholar.d.ts +12 -0
- package/lib/search/providers/googlescholar.js +154 -0
- package/lib/search/providers/index.js +2 -0
- package/lib/search/query-adapter.js +3 -0
- package/lib/search/rate-limit.js +2 -1
- package/lib/search/types.d.ts +35 -13
- package/lib/search/types.js +25 -1
- package/package.json +1 -1
package/lib/host/router.js
CHANGED
|
@@ -6,19 +6,26 @@
|
|
|
6
6
|
* `sec-fetch-site: cross-site`。写端点额外要求自定义头 + JSON content-type,
|
|
7
7
|
* 两者一起让浏览器必须预检,堵掉跨站表单与简单请求。
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
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 =
|
|
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
|
|
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 === '
|
|
667
|
+
if (op === 'recheck') {
|
|
669
668
|
if (req.method !== 'POST') {
|
|
670
|
-
fail(res, 405, 'bad_request', '
|
|
669
|
+
fail(res, 405, 'bad_request', 'recheck 仅支持 POST');
|
|
671
670
|
return;
|
|
672
671
|
}
|
|
673
|
-
ok(res, await service.
|
|
672
|
+
ok(res, await service.recheckPdf(id, paperId));
|
|
674
673
|
return;
|
|
675
674
|
}
|
|
676
|
-
if (op === '
|
|
677
|
-
if (req.method !== '
|
|
678
|
-
fail(res, 405, 'bad_request', '
|
|
675
|
+
if (op === 'download') {
|
|
676
|
+
if (req.method !== 'POST') {
|
|
677
|
+
fail(res, 405, 'bad_request', 'download 仅支持 POST');
|
|
679
678
|
return;
|
|
680
679
|
}
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
package/lib/host/tasks/filter.js
CHANGED
|
@@ -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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return
|
|
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 [
|
package/lib/host/tasks/runner.js
CHANGED
|
@@ -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
|
-
/** 写入筛读 /
|
|
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
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
/** 单源诊断:用固定短查询测一次连通性与延迟(不受冷却影响)。 */
|