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
package/lib/host/router.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* `sec-fetch-site: cross-site`。写端点额外要求自定义头 + JSON content-type,
|
|
7
7
|
* 两者一起让浏览器必须预检,堵掉跨站表单与简单请求。
|
|
8
8
|
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
11
|
+
import { dirname, extname, isAbsolute, resolve } from 'node:path';
|
|
9
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';
|
|
13
16
|
import { TASK_LANGUAGES } from '../search/types.js';
|
|
17
|
+
import { resolvePdfRoot } from './pdf/store.js';
|
|
14
18
|
import { resolveSessionWorkspace } from './workspace.js';
|
|
15
19
|
const MAX_BODY_BYTES = 1_000_000;
|
|
16
20
|
const SORT_MODES = ['relevance', 'citations', 'year'];
|
|
@@ -21,6 +25,7 @@ const PDF_STATUSES = [
|
|
|
21
25
|
'unknown',
|
|
22
26
|
'available',
|
|
23
27
|
'downloaded',
|
|
28
|
+
'missing',
|
|
24
29
|
'html-only',
|
|
25
30
|
'paywalled',
|
|
26
31
|
'blocked',
|
|
@@ -81,48 +86,6 @@ function sendFile(res, file) {
|
|
|
81
86
|
});
|
|
82
87
|
res.end(body);
|
|
83
88
|
}
|
|
84
|
-
/** 内联 PDF,支持单段 Range(预览器会按需拉取)。 */
|
|
85
|
-
function sendPdf(req, res, buffer, filename) {
|
|
86
|
-
const total = buffer.length;
|
|
87
|
-
const disposition = `inline; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
88
|
-
const range = req.headers.range;
|
|
89
|
-
if (range === undefined) {
|
|
90
|
-
res.writeHead(200, {
|
|
91
|
-
'content-type': 'application/pdf',
|
|
92
|
-
'content-length': total,
|
|
93
|
-
'content-disposition': disposition,
|
|
94
|
-
'accept-ranges': 'bytes',
|
|
95
|
-
'cache-control': 'no-store',
|
|
96
|
-
});
|
|
97
|
-
res.end(buffer);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const match = /^bytes=(\d*)-(\d*)$/u.exec(range.trim());
|
|
101
|
-
const start = match?.[1] === '' ? Number.NaN : Number(match?.[1]);
|
|
102
|
-
const end = match?.[2] === '' ? Number.NaN : Number(match?.[2]);
|
|
103
|
-
if (match === null || (!Number.isFinite(start) && !Number.isFinite(end))) {
|
|
104
|
-
res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
|
|
105
|
-
res.end();
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
const from = Number.isFinite(start) ? Math.max(0, start) : Math.max(0, total - (end || 0));
|
|
109
|
-
const to = Number.isFinite(end) ? Math.min(end, total - 1) : total - 1;
|
|
110
|
-
if (from > to || from >= total) {
|
|
111
|
-
res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
|
|
112
|
-
res.end();
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
const chunk = buffer.subarray(from, to + 1);
|
|
116
|
-
res.writeHead(206, {
|
|
117
|
-
'content-type': 'application/pdf',
|
|
118
|
-
'content-length': chunk.length,
|
|
119
|
-
'content-range': `bytes ${from}-${to}/${total}`,
|
|
120
|
-
'content-disposition': disposition,
|
|
121
|
-
'accept-ranges': 'bytes',
|
|
122
|
-
'cache-control': 'no-store',
|
|
123
|
-
});
|
|
124
|
-
res.end(chunk);
|
|
125
|
-
}
|
|
126
89
|
function sendHtml(res, status, html) {
|
|
127
90
|
res.writeHead(status, {
|
|
128
91
|
'content-type': 'text/html; charset=utf-8',
|
|
@@ -130,6 +93,52 @@ function sendHtml(res, status, html) {
|
|
|
130
93
|
});
|
|
131
94
|
res.end(html);
|
|
132
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
|
+
}
|
|
133
142
|
function sendHtmlError(res, error) {
|
|
134
143
|
const { status, error: apiError } = toApiError(error);
|
|
135
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>`);
|
|
@@ -238,6 +247,45 @@ function readStringArray(value, name, maxItems, maxLength) {
|
|
|
238
247
|
}
|
|
239
248
|
return value.map((item) => item.trim().slice(0, maxLength)).filter((item) => item !== '');
|
|
240
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* 解析客户端上报的当前视图快照。
|
|
252
|
+
*
|
|
253
|
+
* 只按白名单取值,未知字段 / 非基本类型一律丢弃;会话 id 由请求头决定,
|
|
254
|
+
* 时间由 Host 重置,避免客户端伪造别的会话或让快照永不过期。
|
|
255
|
+
*/
|
|
256
|
+
function readViewSnapshot(body, sessionId) {
|
|
257
|
+
const taskId = readString(body['taskId'], 'taskId', 200) ?? '';
|
|
258
|
+
if (taskId === '')
|
|
259
|
+
throw new SearchError('bad_request', '缺少 taskId');
|
|
260
|
+
const filters = {};
|
|
261
|
+
const rawFilters = body['filters'];
|
|
262
|
+
if (rawFilters !== null && typeof rawFilters === 'object' && !Array.isArray(rawFilters)) {
|
|
263
|
+
for (const [key, value] of Object.entries(asRecord(rawFilters))) {
|
|
264
|
+
const name = key.slice(0, 60);
|
|
265
|
+
if (typeof value === 'string' && value.length <= 200)
|
|
266
|
+
filters[name] = value;
|
|
267
|
+
else if (typeof value === 'number' && Number.isFinite(value))
|
|
268
|
+
filters[name] = value;
|
|
269
|
+
else if (typeof value === 'boolean')
|
|
270
|
+
filters[name] = value;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const sort = readEnum(body['sort'], 'sort', SORT_MODES);
|
|
274
|
+
const total = readInt(body['total'], 'total', 0, 1_000_000);
|
|
275
|
+
return {
|
|
276
|
+
sessionId,
|
|
277
|
+
taskId,
|
|
278
|
+
at: Date.now(),
|
|
279
|
+
pageIndex: readInt(body['pageIndex'], 'pageIndex', 0, 1_000_000) ?? 0,
|
|
280
|
+
pageSize: readInt(body['pageSize'], 'pageSize', 1, 200) ?? 20,
|
|
281
|
+
...(total === undefined ? {} : { total }),
|
|
282
|
+
...(sort === undefined ? {} : { sort }),
|
|
283
|
+
filters,
|
|
284
|
+
pagePaperIds: readStringArray(body['pagePaperIds'], 'pagePaperIds', 200, 200) ?? [],
|
|
285
|
+
selectedIds: readStringArray(body['selectedIds'], 'selectedIds', 1500, 200) ?? [],
|
|
286
|
+
rev: readInt(body['rev'], 'rev', 0, 1_000_000_000) ?? 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
241
289
|
function readQueriesValue(value) {
|
|
242
290
|
if (value === undefined || value === null)
|
|
243
291
|
return undefined;
|
|
@@ -300,26 +348,6 @@ function readAnnotations(value) {
|
|
|
300
348
|
if (groupId !== undefined)
|
|
301
349
|
input.groupId = groupId;
|
|
302
350
|
}
|
|
303
|
-
if (record['shortlistOrder'] === null)
|
|
304
|
-
input.shortlistOrder = null;
|
|
305
|
-
else if (record['shortlistOrder'] !== undefined) {
|
|
306
|
-
const order = readInt(record['shortlistOrder'], 'shortlistOrder', 0, 100_000);
|
|
307
|
-
if (order !== undefined)
|
|
308
|
-
input.shortlistOrder = order;
|
|
309
|
-
}
|
|
310
|
-
if (record['note'] !== undefined) {
|
|
311
|
-
if (typeof record['note'] !== 'string') {
|
|
312
|
-
throw new SearchError('bad_request', 'note 必须是字符串');
|
|
313
|
-
}
|
|
314
|
-
input.note = record['note'].slice(0, 4_000);
|
|
315
|
-
}
|
|
316
|
-
if (record['tags'] !== undefined) {
|
|
317
|
-
const tags = readStringArray(record['tags'], 'tags', 20, 40);
|
|
318
|
-
if (tags !== undefined && tags !== null)
|
|
319
|
-
input.tags = tags;
|
|
320
|
-
else if (tags === null)
|
|
321
|
-
input.tags = [];
|
|
322
|
-
}
|
|
323
351
|
return input;
|
|
324
352
|
});
|
|
325
353
|
}
|
|
@@ -590,7 +618,7 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
|
|
|
590
618
|
const action = seg[2];
|
|
591
619
|
if (action === undefined) {
|
|
592
620
|
if (req.method === 'GET') {
|
|
593
|
-
ok(res, await service.getDetail(id));
|
|
621
|
+
ok(res, await service.getDetail(id, await resolveSessionWorkspace(sessionId)));
|
|
594
622
|
return;
|
|
595
623
|
}
|
|
596
624
|
if (req.method === 'PATCH') {
|
|
@@ -664,7 +692,7 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
|
|
|
664
692
|
fail(res, 405, 'bad_request', '论文详情仅支持 GET');
|
|
665
693
|
return;
|
|
666
694
|
}
|
|
667
|
-
ok(res, await service.paperDetail(id, paperId));
|
|
695
|
+
ok(res, await service.paperDetail(id, paperId, await resolveSessionWorkspace(sessionId)));
|
|
668
696
|
return;
|
|
669
697
|
}
|
|
670
698
|
if (op === 'entities') {
|
|
@@ -675,27 +703,20 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
|
|
|
675
703
|
ok(res, await service.entities(id, paperId));
|
|
676
704
|
return;
|
|
677
705
|
}
|
|
678
|
-
if (op === '
|
|
706
|
+
if (op === 'recheck') {
|
|
679
707
|
if (req.method !== 'POST') {
|
|
680
|
-
fail(res, 405, 'bad_request', '
|
|
708
|
+
fail(res, 405, 'bad_request', 'recheck 仅支持 POST');
|
|
681
709
|
return;
|
|
682
710
|
}
|
|
683
|
-
ok(res, await service.
|
|
711
|
+
ok(res, await service.recheckPdf(id, paperId));
|
|
684
712
|
return;
|
|
685
713
|
}
|
|
686
|
-
if (op === '
|
|
687
|
-
if (req.method !== '
|
|
688
|
-
fail(res, 405, 'bad_request', '
|
|
714
|
+
if (op === 'download') {
|
|
715
|
+
if (req.method !== 'POST') {
|
|
716
|
+
fail(res, 405, 'bad_request', 'download 仅支持 POST');
|
|
689
717
|
return;
|
|
690
718
|
}
|
|
691
|
-
|
|
692
|
-
const file = await service.previewPdf(id, paperId, await resolveSessionWorkspace(sessionId));
|
|
693
|
-
sendPdf(req, res, file.buffer, file.filename);
|
|
694
|
-
}
|
|
695
|
-
catch (error) {
|
|
696
|
-
const { status, error: apiError } = toApiError(error);
|
|
697
|
-
fail(res, status === 500 ? 502 : status, apiError.code, apiError.message);
|
|
698
|
-
}
|
|
719
|
+
ok(res, await service.downloadPdf(id, paperId, await resolveSessionWorkspace(sessionId)));
|
|
699
720
|
return;
|
|
700
721
|
}
|
|
701
722
|
if (op === 'fulltext') {
|
|
@@ -721,7 +742,17 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
|
|
|
721
742
|
}
|
|
722
743
|
const body = await readJsonBody(req);
|
|
723
744
|
const ids = readStringArray(body['paperIds'], 'paperIds', 50, 80);
|
|
724
|
-
|
|
745
|
+
const targetId = readString(body['target'], 'target', 40);
|
|
746
|
+
const targetName = readString(body['targetName'], 'targetName', 200);
|
|
747
|
+
const targetFilesEditable = body['targetFilesEditable'];
|
|
748
|
+
const target = targetId === undefined || targetId === ''
|
|
749
|
+
? undefined
|
|
750
|
+
: {
|
|
751
|
+
id: targetId,
|
|
752
|
+
...(targetName === undefined || targetName === '' ? {} : { name: targetName }),
|
|
753
|
+
...(typeof targetFilesEditable === 'boolean' ? { filesEditable: targetFilesEditable } : {}),
|
|
754
|
+
};
|
|
755
|
+
ok(res, await service.zoteroSync(id, ids === undefined || ids === null ? undefined : ids, await resolveSessionWorkspace(sessionId), target));
|
|
725
756
|
return;
|
|
726
757
|
}
|
|
727
758
|
if (action === 'compare') {
|
|
@@ -802,6 +833,38 @@ export function createHandler(service) {
|
|
|
802
833
|
ok(res, await service.eventsSince(Number.isFinite(sinceRev) ? sinceRev : 0));
|
|
803
834
|
return;
|
|
804
835
|
}
|
|
836
|
+
if (head === 'view') {
|
|
837
|
+
if (req.method !== 'POST') {
|
|
838
|
+
fail(res, 405, 'bad_request', 'view 仅支持 POST');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const sessionId = readSessionId(req, url.searchParams);
|
|
842
|
+
if (sessionId === undefined)
|
|
843
|
+
throw new SearchError('bad_request', '缺少会话 id');
|
|
844
|
+
service.saveViewSnapshot(sessionId, readViewSnapshot(await readJsonBody(req), sessionId));
|
|
845
|
+
ok(res, { saved: true });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (head === 'files') {
|
|
849
|
+
if (seg[1] !== 'reveal') {
|
|
850
|
+
fail(res, 404, 'not_found', `未知文件操作 ${seg[1] ?? ''}`);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (req.method !== 'POST') {
|
|
854
|
+
fail(res, 405, 'bad_request', 'reveal 仅支持 POST');
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const body = await readJsonBody(req);
|
|
858
|
+
const path = readString(body['path'], 'path', 4096);
|
|
859
|
+
if (path === undefined || path === '') {
|
|
860
|
+
throw new SearchError('bad_request', '缺少 path 参数');
|
|
861
|
+
}
|
|
862
|
+
if (!isAbsolute(path))
|
|
863
|
+
throw new SearchError('bad_request', 'path 必须是绝对路径');
|
|
864
|
+
const workspace = await resolveSessionWorkspace(readSessionId(req, url.searchParams));
|
|
865
|
+
ok(res, await revealInFileManager(path, resolvePdfRoot(workspace)));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
805
868
|
if (head === 'settings') {
|
|
806
869
|
if (seg[1] === 'probe' && req.method === 'POST') {
|
|
807
870
|
const source = readString((await readJsonBody(req))['source'], 'source', 40);
|
|
@@ -816,6 +879,10 @@ export function createHandler(service) {
|
|
|
816
879
|
ok(res, await service.cleanup(scope));
|
|
817
880
|
return;
|
|
818
881
|
}
|
|
882
|
+
if (seg[1] === 'zotero' && seg[2] === 'targets' && req.method === 'POST') {
|
|
883
|
+
ok(res, await service.zoteroTargets());
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
819
886
|
if (seg[1] === 'zotero' && req.method === 'POST') {
|
|
820
887
|
ok(res, await service.zoteroProbe());
|
|
821
888
|
return;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型下载指令的范围解析(纯函数,便于单测)。
|
|
3
|
+
*
|
|
4
|
+
* 模型跑在 Host 侧,看不到右侧栏翻到第几页、用了什么筛选。范围统一由视图
|
|
5
|
+
* 快照 + 服务端 `filterPapers` 算准,并逐个与任务结果取交集,最终只把一份
|
|
6
|
+
* 「已校验的 paperId 清单」交给 Client —— 浏览器不再解析任何条件,也不会
|
|
7
|
+
* 出现「模型理解的筛选」与「界面筛选」两套口径。
|
|
8
|
+
*/
|
|
9
|
+
import type { DownloadCommandItem, DownloadScope, PageBase, Paper, SearchTask, ViewSnapshot } from '../../search/types.js';
|
|
10
|
+
import type { PaperFilter } from './filter.js';
|
|
11
|
+
/**
|
|
12
|
+
* 把视图快照的扁平筛选投影收窄回 `PaperFilter`。
|
|
13
|
+
*
|
|
14
|
+
* 快照来自 Client 的 `ResultsQuery`,键名与筛选口径一致;这里只按白名单取,
|
|
15
|
+
* 未知键与非法枚举值一律忽略,避免模型/前端写入任意字段影响后台过滤。
|
|
16
|
+
*/
|
|
17
|
+
export declare function toPaperFilter(raw: ViewSnapshot['filters']): PaperFilter;
|
|
18
|
+
/** 视图筛选打底,工具显式字段覆盖同名项;显式值为 undefined 的不覆盖。 */
|
|
19
|
+
export declare function mergeDownloadFilter(base: PaperFilter, explicit: PaperFilter): PaperFilter;
|
|
20
|
+
export interface ResolveDownloadOptions {
|
|
21
|
+
scope: DownloadScope;
|
|
22
|
+
/** 已经合并好的筛选条件(视图筛选 + 工具显式条件),用于范围说明。 */
|
|
23
|
+
filter: PaperFilter;
|
|
24
|
+
/**
|
|
25
|
+
* 调用方用 `filterPapers(task, filter)` 得到的论文集合。
|
|
26
|
+
*
|
|
27
|
+
* 本文件刻意不引入运行时模块依赖,筛选只在一处发生,避免模型下载与
|
|
28
|
+
* 界面结果出现两套口径;全量范围也不受 `/results` 的 200 条分页限制。
|
|
29
|
+
*/
|
|
30
|
+
matched: readonly Paper[];
|
|
31
|
+
/** `scope='ids'` 时模型给出的显式清单。 */
|
|
32
|
+
explicitIds?: readonly string[];
|
|
33
|
+
/** 视图快照;`currentPage` / `currentView` / `pages` 需要它。 */
|
|
34
|
+
snapshot?: ViewSnapshot;
|
|
35
|
+
/** 快照过期时由服务层重算的当前页 paperId(保持用户所见顺序)。 */
|
|
36
|
+
pagePaperIds?: readonly string[];
|
|
37
|
+
/** `scope='pages'` 时模型给的 1-based 页码。 */
|
|
38
|
+
pages?: readonly number[];
|
|
39
|
+
/** 页码基准,默认 `view`(先切页再套附加条件)。 */
|
|
40
|
+
pageBase?: PageBase;
|
|
41
|
+
/**
|
|
42
|
+
* `pageBase='view'` 时切页用的集合:只含视图筛选、**不含**工具附加条件。
|
|
43
|
+
*
|
|
44
|
+
* 缺省回落到 `matched`(即先筛后切页),与 `pageBase='filtered'` 等价。
|
|
45
|
+
* 与 `matched` 一样,需由调用方按界面排序排好后再传入。
|
|
46
|
+
*/
|
|
47
|
+
viewMatched?: readonly Paper[];
|
|
48
|
+
/** 每页篇数;缺省取客户端上报值。 */
|
|
49
|
+
pageSize?: number;
|
|
50
|
+
/** 分组名,仅用于范围说明。 */
|
|
51
|
+
groupName?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface DownloadScopeResult {
|
|
54
|
+
paperIds: string[];
|
|
55
|
+
items: DownloadCommandItem[];
|
|
56
|
+
/** 清单里不属于该任务或不满足条件的 id 数。 */
|
|
57
|
+
ignored: number;
|
|
58
|
+
scopeLabel: string;
|
|
59
|
+
/** `scope='pages'` 时按当前每页篇数算出的总页数。 */
|
|
60
|
+
pageCount?: number;
|
|
61
|
+
/** 请求但超出总页数的页码(1-based);非空时调用方应拒绝下发。 */
|
|
62
|
+
outOfRange?: number[];
|
|
63
|
+
}
|
|
64
|
+
export interface PageWindowOptions {
|
|
65
|
+
pages: readonly number[];
|
|
66
|
+
pageBase: PageBase;
|
|
67
|
+
/** `pageBase='filtered'` 的基础集合(筛选已全部合并)。 */
|
|
68
|
+
matched: readonly Paper[];
|
|
69
|
+
/** `pageBase='view'` 的基础集合(只含视图筛选)。 */
|
|
70
|
+
viewMatched: readonly Paper[];
|
|
71
|
+
/** 每页篇数;缺省用 `FALLBACK_PAGE_SIZE`。 */
|
|
72
|
+
pageSize?: number;
|
|
73
|
+
}
|
|
74
|
+
export interface PageWindowResult {
|
|
75
|
+
/** 页窗口内按页序(跨页不重复)展开的 paperId。 */
|
|
76
|
+
paperIds: string[];
|
|
77
|
+
pageSize: number;
|
|
78
|
+
pageCount: number;
|
|
79
|
+
outOfRange: number[];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 把「第 4–5 页」解析成 paperId 清单。
|
|
83
|
+
*
|
|
84
|
+
* 页码语义固定在「用户屏幕上那一页」:每页 `pageSize` 条(面板恒为 20)。
|
|
85
|
+
* **前置条件**:`matched` / `viewMatched` 必须已按界面排序排好(调用方用
|
|
86
|
+
* `filter.sortPapers`),否则切出来的页与面板不一致。
|
|
87
|
+
*
|
|
88
|
+
* `pageBase='view'` 时先切页、再与附加条件取交集,这样「第二页里有 PDF 的
|
|
89
|
+
* 文献」不会因为加了 `hasPdf` 而把页面整体前移。
|
|
90
|
+
*/
|
|
91
|
+
export declare function resolvePageWindow(options: PageWindowOptions): PageWindowResult;
|
|
92
|
+
/**
|
|
93
|
+
* 解析范围 → 已校验的 paperId 清单。
|
|
94
|
+
*
|
|
95
|
+
* `ids` 按模型给定顺序、`currentPage` 按用户当前页顺序,其余按任务结果原序;
|
|
96
|
+
* 每种范围都会再与 `filterPapers` 的结果取交集,因此叠加 `hasPdf`、年份等
|
|
97
|
+
* 条件时与界面口径完全一致。全量直接用纯函数过滤,不受 `/results` 的 200
|
|
98
|
+
* 条分页上限约束。
|
|
99
|
+
*/
|
|
100
|
+
export declare function resolveDownloadScope(task: SearchTask, options: ResolveDownloadOptions): DownloadScopeResult;
|
|
101
|
+
/** 生成给弹窗展示的中文范围说明(含篇数);Client 只显示、不翻译、不再解析。 */
|
|
102
|
+
export declare function buildScopeLabel(scope: DownloadScope, count: number, options: Pick<ResolveDownloadOptions, 'filter' | 'snapshot' | 'groupName' | 'pages'>): string;
|
|
103
|
+
/** 页码列表 → 可读文案:`[2]` → `2`,`[4,5,6]` → `4–6`,`[2,5]` → `2、5`。 */
|
|
104
|
+
export declare function describePages(pages: readonly number[]): string;
|