dsh-plugin-litsearch 0.3.5 → 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 +89 -14
- package/client/client.js +1192 -108
- package/lib/host/events.d.ts +9 -1
- package/lib/host/events.js +50 -3
- package/lib/host/router.js +66 -1
- 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 +34 -3
- package/lib/host/tasks/service.js +130 -17
- package/lib/host/tools.d.ts +4 -0
- package/lib/host/tools.js +808 -9
- 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/types.d.ts +136 -1
- package/lib/search/types.js +7 -0
- package/package.json +1 -1
package/lib/host/events.d.ts
CHANGED
|
@@ -5,15 +5,23 @@
|
|
|
5
5
|
* 的 globalRev」日志;客户端按 `sinceRev` 轮询拿增量。globalRev 只在宿主进程内
|
|
6
6
|
* 单调递增,重启后归零 —— 客户端发现 `sinceRev` 比服务端大就以 `reset` 重新同步。
|
|
7
7
|
*/
|
|
8
|
-
import type { EventsPage, TaskSummary } from '../search/types.js';
|
|
8
|
+
import type { DownloadCommand, EventsPage, TaskSummary, ZoteroCommand } from '../search/types.js';
|
|
9
9
|
export declare class TaskEvents {
|
|
10
10
|
private rev;
|
|
11
11
|
private readonly entries;
|
|
12
12
|
private focus;
|
|
13
|
+
/** 下载指令日志:数组而非单值,两次轮询之间连发多条也不会互相覆盖。 */
|
|
14
|
+
private readonly downloadEntries;
|
|
15
|
+
/** Zotero 同步指令日志,与下载并列,互不覆盖。 */
|
|
16
|
+
private readonly zoteroEntries;
|
|
13
17
|
get currentRev(): number;
|
|
14
18
|
publish(summary: TaskSummary): void;
|
|
15
19
|
/** 请求客户端把某个任务 tab 打开/切到前台(面板关着时重新打开)。 */
|
|
16
20
|
requestFocus(taskId: string, title: string, sessionId?: string): void;
|
|
21
|
+
/** 下发一条模型批量下载指令;与 focus 一样推进 globalRev 走增量轮询。 */
|
|
22
|
+
publishDownload(command: DownloadCommand): void;
|
|
23
|
+
/** 下发一条模型 Zotero 同步指令;与下载指令一样推进 globalRev 走增量轮询。 */
|
|
24
|
+
publishZotero(command: ZoteroCommand): void;
|
|
17
25
|
remove(id: string): void;
|
|
18
26
|
since(sinceRev: number): EventsPage;
|
|
19
27
|
}
|
package/lib/host/events.js
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* 的 globalRev」日志;客户端按 `sinceRev` 轮询拿增量。globalRev 只在宿主进程内
|
|
6
6
|
* 单调递增,重启后归零 —— 客户端发现 `sinceRev` 比服务端大就以 `reset` 重新同步。
|
|
7
7
|
*/
|
|
8
|
+
/** 事件日志里最多保留多少条模型指令,避免长时间运行无限增长。 */
|
|
9
|
+
const MAX_DOWNLOAD_ENTRIES = 50;
|
|
10
|
+
const MAX_ZOTERO_ENTRIES = 50;
|
|
8
11
|
export class TaskEvents {
|
|
9
12
|
rev = 0;
|
|
10
13
|
entries = new Map();
|
|
11
14
|
focus;
|
|
15
|
+
/** 下载指令日志:数组而非单值,两次轮询之间连发多条也不会互相覆盖。 */
|
|
16
|
+
downloadEntries = [];
|
|
17
|
+
/** Zotero 同步指令日志,与下载并列,互不覆盖。 */
|
|
18
|
+
zoteroEntries = [];
|
|
12
19
|
get currentRev() {
|
|
13
20
|
return this.rev;
|
|
14
21
|
}
|
|
@@ -27,17 +34,42 @@ export class TaskEvents {
|
|
|
27
34
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
28
35
|
};
|
|
29
36
|
}
|
|
37
|
+
/** 下发一条模型批量下载指令;与 focus 一样推进 globalRev 走增量轮询。 */
|
|
38
|
+
publishDownload(command) {
|
|
39
|
+
this.rev += 1;
|
|
40
|
+
this.downloadEntries.push({ globalRev: this.rev, command });
|
|
41
|
+
if (this.downloadEntries.length > MAX_DOWNLOAD_ENTRIES) {
|
|
42
|
+
this.downloadEntries.splice(0, this.downloadEntries.length - MAX_DOWNLOAD_ENTRIES);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** 下发一条模型 Zotero 同步指令;与下载指令一样推进 globalRev 走增量轮询。 */
|
|
46
|
+
publishZotero(command) {
|
|
47
|
+
this.rev += 1;
|
|
48
|
+
this.zoteroEntries.push({ globalRev: this.rev, command });
|
|
49
|
+
if (this.zoteroEntries.length > MAX_ZOTERO_ENTRIES) {
|
|
50
|
+
this.zoteroEntries.splice(0, this.zoteroEntries.length - MAX_ZOTERO_ENTRIES);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
30
53
|
remove(id) {
|
|
31
54
|
this.entries.delete(id);
|
|
32
55
|
if (this.focus?.taskId === id)
|
|
33
56
|
this.focus = undefined;
|
|
57
|
+
for (let index = this.downloadEntries.length - 1; index >= 0; index -= 1) {
|
|
58
|
+
if (this.downloadEntries[index]?.command.taskId === id)
|
|
59
|
+
this.downloadEntries.splice(index, 1);
|
|
60
|
+
}
|
|
61
|
+
for (let index = this.zoteroEntries.length - 1; index >= 0; index -= 1) {
|
|
62
|
+
if (this.zoteroEntries[index]?.command.taskId === id)
|
|
63
|
+
this.zoteroEntries.splice(index, 1);
|
|
64
|
+
}
|
|
34
65
|
}
|
|
35
66
|
since(sinceRev) {
|
|
36
67
|
if (!Number.isFinite(sinceRev) || sinceRev < 0) {
|
|
37
|
-
|
|
68
|
+
// 宿主重启 / 客户端状态错乱:重新对齐,不重放历史模型指令。
|
|
69
|
+
return { rev: this.rev, reset: true, tasks: [], downloads: [], zotero: [] };
|
|
38
70
|
}
|
|
39
71
|
if (sinceRev > this.rev) {
|
|
40
|
-
return { rev: this.rev, reset: true, tasks: [] };
|
|
72
|
+
return { rev: this.rev, reset: true, tasks: [], downloads: [], zotero: [] };
|
|
41
73
|
}
|
|
42
74
|
const tasks = [...this.entries.values()]
|
|
43
75
|
.filter((entry) => entry.globalRev > sinceRev)
|
|
@@ -53,6 +85,21 @@ export class TaskEvents {
|
|
|
53
85
|
: { sessionId: this.focus.sessionId }),
|
|
54
86
|
}
|
|
55
87
|
: undefined;
|
|
56
|
-
|
|
88
|
+
const downloads = this.downloadEntries
|
|
89
|
+
.filter((entry) => entry.globalRev > sinceRev)
|
|
90
|
+
.sort((a, b) => a.globalRev - b.globalRev)
|
|
91
|
+
.map((entry) => entry.command);
|
|
92
|
+
const zotero = this.zoteroEntries
|
|
93
|
+
.filter((entry) => entry.globalRev > sinceRev)
|
|
94
|
+
.sort((a, b) => a.globalRev - b.globalRev)
|
|
95
|
+
.map((entry) => entry.command);
|
|
96
|
+
return {
|
|
97
|
+
rev: this.rev,
|
|
98
|
+
reset: false,
|
|
99
|
+
tasks,
|
|
100
|
+
...(focus === undefined ? {} : { focus }),
|
|
101
|
+
downloads,
|
|
102
|
+
zotero,
|
|
103
|
+
};
|
|
57
104
|
}
|
|
58
105
|
}
|
package/lib/host/router.js
CHANGED
|
@@ -247,6 +247,45 @@ function readStringArray(value, name, maxItems, maxLength) {
|
|
|
247
247
|
}
|
|
248
248
|
return value.map((item) => item.trim().slice(0, maxLength)).filter((item) => item !== '');
|
|
249
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
|
+
}
|
|
250
289
|
function readQueriesValue(value) {
|
|
251
290
|
if (value === undefined || value === null)
|
|
252
291
|
return undefined;
|
|
@@ -703,7 +742,17 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
|
|
|
703
742
|
}
|
|
704
743
|
const body = await readJsonBody(req);
|
|
705
744
|
const ids = readStringArray(body['paperIds'], 'paperIds', 50, 80);
|
|
706
|
-
|
|
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));
|
|
707
756
|
return;
|
|
708
757
|
}
|
|
709
758
|
if (action === 'compare') {
|
|
@@ -784,6 +833,18 @@ export function createHandler(service) {
|
|
|
784
833
|
ok(res, await service.eventsSince(Number.isFinite(sinceRev) ? sinceRev : 0));
|
|
785
834
|
return;
|
|
786
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
|
+
}
|
|
787
848
|
if (head === 'files') {
|
|
788
849
|
if (seg[1] !== 'reveal') {
|
|
789
850
|
fail(res, 404, 'not_found', `未知文件操作 ${seg[1] ?? ''}`);
|
|
@@ -818,6 +879,10 @@ export function createHandler(service) {
|
|
|
818
879
|
ok(res, await service.cleanup(scope));
|
|
819
880
|
return;
|
|
820
881
|
}
|
|
882
|
+
if (seg[1] === 'zotero' && seg[2] === 'targets' && req.method === 'POST') {
|
|
883
|
+
ok(res, await service.zoteroTargets());
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
821
886
|
if (seg[1] === 'zotero' && req.method === 'POST') {
|
|
822
887
|
ok(res, await service.zoteroProbe());
|
|
823
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;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型下载指令的范围解析(纯函数,便于单测)。
|
|
3
|
+
*
|
|
4
|
+
* 模型跑在 Host 侧,看不到右侧栏翻到第几页、用了什么筛选。范围统一由视图
|
|
5
|
+
* 快照 + 服务端 `filterPapers` 算准,并逐个与任务结果取交集,最终只把一份
|
|
6
|
+
* 「已校验的 paperId 清单」交给 Client —— 浏览器不再解析任何条件,也不会
|
|
7
|
+
* 出现「模型理解的筛选」与「界面筛选」两套口径。
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 每页篇数的兜底值,与 `types.ts` 的 `RESULT_PAGE_SIZE`(面板分页粒度)一致。
|
|
11
|
+
*
|
|
12
|
+
* 本文件要能被 `node --test` 直接加载,因此不做任何运行时模块导入(`.js`
|
|
13
|
+
* 后缀在同环境里无法回落到 `.ts`);真正的每页条数总由调用方通过视图快照
|
|
14
|
+
* 传入,这里只兜底。`test/download-scope.test.ts` 会断言它仍是 20。
|
|
15
|
+
*/
|
|
16
|
+
const FALLBACK_PAGE_SIZE = 20;
|
|
17
|
+
/**
|
|
18
|
+
* 与 `types.ts` 的 `TASK_LANGUAGES` 一致。这里刻意只做类型导入、不产生运行时
|
|
19
|
+
* 模块依赖:`src` 源码可被 `node --test` 直接加载,Host 构建产物仍走 `.js`。
|
|
20
|
+
*/
|
|
21
|
+
const LANGUAGE_VALUES = [
|
|
22
|
+
'any',
|
|
23
|
+
'eng',
|
|
24
|
+
'chi',
|
|
25
|
+
'jpn',
|
|
26
|
+
'kor',
|
|
27
|
+
'fre',
|
|
28
|
+
'ger',
|
|
29
|
+
'spa',
|
|
30
|
+
'rus',
|
|
31
|
+
'ita',
|
|
32
|
+
'por',
|
|
33
|
+
];
|
|
34
|
+
const SCREENING_VALUES = ['unread', 'read', 'included', 'excluded'];
|
|
35
|
+
const PDF_STATUS_VALUES = [
|
|
36
|
+
'unknown',
|
|
37
|
+
'available',
|
|
38
|
+
'downloaded',
|
|
39
|
+
'missing',
|
|
40
|
+
'html-only',
|
|
41
|
+
'paywalled',
|
|
42
|
+
'blocked',
|
|
43
|
+
'failed',
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* 把视图快照的扁平筛选投影收窄回 `PaperFilter`。
|
|
47
|
+
*
|
|
48
|
+
* 快照来自 Client 的 `ResultsQuery`,键名与筛选口径一致;这里只按白名单取,
|
|
49
|
+
* 未知键与非法枚举值一律忽略,避免模型/前端写入任意字段影响后台过滤。
|
|
50
|
+
*/
|
|
51
|
+
export function toPaperFilter(raw) {
|
|
52
|
+
const text = (key) => {
|
|
53
|
+
const value = raw[key];
|
|
54
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
55
|
+
};
|
|
56
|
+
const number = (key) => {
|
|
57
|
+
const value = raw[key];
|
|
58
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
59
|
+
};
|
|
60
|
+
const flag = (key) => (raw[key] === true ? true : undefined);
|
|
61
|
+
const oneOf = (key, values) => {
|
|
62
|
+
const value = raw[key];
|
|
63
|
+
return typeof value === 'string' ? values.find((item) => item === value) : undefined;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
source: text('source'),
|
|
67
|
+
q: text('q'),
|
|
68
|
+
queryId: text('queryId'),
|
|
69
|
+
type: text('type'),
|
|
70
|
+
venue: text('venue'),
|
|
71
|
+
author: text('author'),
|
|
72
|
+
groupId: text('groupId'),
|
|
73
|
+
yearFrom: number('yearFrom'),
|
|
74
|
+
yearTo: number('yearTo'),
|
|
75
|
+
openAccessOnly: flag('openAccessOnly'),
|
|
76
|
+
hasPdf: flag('hasPdf'),
|
|
77
|
+
missingFullText: flag('missingFullText'),
|
|
78
|
+
newOnly: flag('newOnly'),
|
|
79
|
+
screening: oneOf('screening', SCREENING_VALUES),
|
|
80
|
+
language: oneOf('language', LANGUAGE_VALUES),
|
|
81
|
+
pdfStatus: oneOf('pdfStatus', PDF_STATUS_VALUES),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** 视图筛选打底,工具显式字段覆盖同名项;显式值为 undefined 的不覆盖。 */
|
|
85
|
+
export function mergeDownloadFilter(base, explicit) {
|
|
86
|
+
const merged = { ...base };
|
|
87
|
+
for (const [key, value] of Object.entries(explicit)) {
|
|
88
|
+
if (value === undefined)
|
|
89
|
+
continue;
|
|
90
|
+
Object.assign(merged, { [key]: value });
|
|
91
|
+
}
|
|
92
|
+
return merged;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 把「第 4–5 页」解析成 paperId 清单。
|
|
96
|
+
*
|
|
97
|
+
* 页码语义固定在「用户屏幕上那一页」:每页 `pageSize` 条(面板恒为 20)。
|
|
98
|
+
* **前置条件**:`matched` / `viewMatched` 必须已按界面排序排好(调用方用
|
|
99
|
+
* `filter.sortPapers`),否则切出来的页与面板不一致。
|
|
100
|
+
*
|
|
101
|
+
* `pageBase='view'` 时先切页、再与附加条件取交集,这样「第二页里有 PDF 的
|
|
102
|
+
* 文献」不会因为加了 `hasPdf` 而把页面整体前移。
|
|
103
|
+
*/
|
|
104
|
+
export function resolvePageWindow(options) {
|
|
105
|
+
const pageSize = Math.max(1, Math.floor(options.pageSize ?? FALLBACK_PAGE_SIZE));
|
|
106
|
+
const base = options.pageBase === 'view' ? options.viewMatched : options.matched;
|
|
107
|
+
const pageCount = Math.max(1, Math.ceil(base.length / pageSize));
|
|
108
|
+
const outOfRange = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const paperIds = [];
|
|
111
|
+
for (const page of [...options.pages].sort((a, b) => a - b)) {
|
|
112
|
+
const index = Math.floor(page) - 1;
|
|
113
|
+
if (index < 0 || page > pageCount) {
|
|
114
|
+
outOfRange.push(page);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const start = index * pageSize;
|
|
118
|
+
for (const paper of base.slice(start, start + pageSize)) {
|
|
119
|
+
if (seen.has(paper.paperId))
|
|
120
|
+
continue;
|
|
121
|
+
seen.add(paper.paperId);
|
|
122
|
+
paperIds.push(paper.paperId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { paperIds, pageSize, pageCount, outOfRange };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 解析范围 → 已校验的 paperId 清单。
|
|
129
|
+
*
|
|
130
|
+
* `ids` 按模型给定顺序、`currentPage` 按用户当前页顺序,其余按任务结果原序;
|
|
131
|
+
* 每种范围都会再与 `filterPapers` 的结果取交集,因此叠加 `hasPdf`、年份等
|
|
132
|
+
* 条件时与界面口径完全一致。全量直接用纯函数过滤,不受 `/results` 的 200
|
|
133
|
+
* 条分页上限约束。
|
|
134
|
+
*/
|
|
135
|
+
export function resolveDownloadScope(task, options) {
|
|
136
|
+
const byId = new Map(task.result.papers.map((paper) => [paper.paperId, paper]));
|
|
137
|
+
const eligible = new Set(options.matched.map((paper) => paper.paperId));
|
|
138
|
+
let ordered;
|
|
139
|
+
let pageCount;
|
|
140
|
+
let outOfRange;
|
|
141
|
+
switch (options.scope) {
|
|
142
|
+
case 'ids': {
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
ordered = [];
|
|
145
|
+
for (const raw of options.explicitIds ?? []) {
|
|
146
|
+
const id = raw.trim();
|
|
147
|
+
if (id === '' || seen.has(id))
|
|
148
|
+
continue;
|
|
149
|
+
seen.add(id);
|
|
150
|
+
ordered.push(id);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'currentPage': {
|
|
155
|
+
ordered = [...(options.pagePaperIds ?? options.snapshot?.pagePaperIds ?? [])];
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case 'pages': {
|
|
159
|
+
const window = resolvePageWindow({
|
|
160
|
+
pages: options.pages ?? [],
|
|
161
|
+
pageBase: options.pageBase ?? 'view',
|
|
162
|
+
matched: options.matched,
|
|
163
|
+
viewMatched: options.viewMatched ?? options.matched,
|
|
164
|
+
...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }),
|
|
165
|
+
});
|
|
166
|
+
ordered = window.paperIds;
|
|
167
|
+
pageCount = window.pageCount;
|
|
168
|
+
outOfRange = window.outOfRange;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
default: {
|
|
172
|
+
ordered = options.matched.map((paper) => paper.paperId);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const paperIds = [];
|
|
177
|
+
const items = [];
|
|
178
|
+
for (const id of ordered) {
|
|
179
|
+
if (!eligible.has(id))
|
|
180
|
+
continue;
|
|
181
|
+
const paper = byId.get(id);
|
|
182
|
+
if (paper === undefined)
|
|
183
|
+
continue;
|
|
184
|
+
paperIds.push(id);
|
|
185
|
+
const meta = [paper.venue, paper.year === null ? '' : String(paper.year)]
|
|
186
|
+
.filter((part) => part !== '')
|
|
187
|
+
.join(' · ');
|
|
188
|
+
items.push({
|
|
189
|
+
paperId: id,
|
|
190
|
+
title: paper.title,
|
|
191
|
+
source: paper.hitSources[0]?.source ?? paper.source,
|
|
192
|
+
...(meta === '' ? {} : { meta }),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
paperIds,
|
|
197
|
+
items,
|
|
198
|
+
ignored: ordered.length - paperIds.length,
|
|
199
|
+
scopeLabel: buildScopeLabel(options.scope, paperIds.length, options),
|
|
200
|
+
...(pageCount === undefined ? {} : { pageCount }),
|
|
201
|
+
...(outOfRange === undefined ? {} : { outOfRange }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** 生成给弹窗展示的中文范围说明(含篇数);Client 只显示、不翻译、不再解析。 */
|
|
205
|
+
export function buildScopeLabel(scope, count, options) {
|
|
206
|
+
const parts = [];
|
|
207
|
+
switch (scope) {
|
|
208
|
+
case 'ids':
|
|
209
|
+
parts.push('指定文献');
|
|
210
|
+
break;
|
|
211
|
+
case 'currentPage':
|
|
212
|
+
parts.push(`当前页(第 ${(options.snapshot?.pageIndex ?? 0) + 1} 页)`);
|
|
213
|
+
break;
|
|
214
|
+
case 'pages':
|
|
215
|
+
parts.push(`第 ${describePages(options.pages ?? [])} 页`);
|
|
216
|
+
break;
|
|
217
|
+
case 'currentView':
|
|
218
|
+
parts.push('当前筛选全部');
|
|
219
|
+
break;
|
|
220
|
+
case 'all':
|
|
221
|
+
parts.push('整个任务全部文献');
|
|
222
|
+
break;
|
|
223
|
+
case 'group':
|
|
224
|
+
parts.push(`分组「${options.groupName ?? options.filter.groupId ?? ''}」`);
|
|
225
|
+
break;
|
|
226
|
+
case 'filters':
|
|
227
|
+
parts.push('按条件筛选');
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
const filter = options.filter;
|
|
231
|
+
if (filter.hasPdf === true)
|
|
232
|
+
parts.push('仅含 PDF 直链或已下载');
|
|
233
|
+
if (filter.openAccessOnly === true)
|
|
234
|
+
parts.push('仅开放获取');
|
|
235
|
+
if (filter.q !== undefined && filter.q !== '')
|
|
236
|
+
parts.push(`关键词「${filter.q}」`);
|
|
237
|
+
if (filter.groupId !== undefined && scope !== 'group')
|
|
238
|
+
parts.push(`分组 ${filter.groupId}`);
|
|
239
|
+
if (filter.yearFrom !== undefined || filter.yearTo !== undefined) {
|
|
240
|
+
parts.push(`${filter.yearFrom ?? '不限'}–${filter.yearTo ?? '不限'} 年`);
|
|
241
|
+
}
|
|
242
|
+
if (filter.source !== undefined)
|
|
243
|
+
parts.push(`来源 ${filter.source}`);
|
|
244
|
+
if (filter.screening !== undefined)
|
|
245
|
+
parts.push(`筛读 ${filter.screening}`);
|
|
246
|
+
if (filter.pdfStatus !== undefined)
|
|
247
|
+
parts.push(`全文状态 ${filter.pdfStatus}`);
|
|
248
|
+
if (filter.venue !== undefined && filter.venue !== '')
|
|
249
|
+
parts.push(`出处 ${filter.venue}`);
|
|
250
|
+
if (filter.author !== undefined && filter.author !== '')
|
|
251
|
+
parts.push(`作者 ${filter.author}`);
|
|
252
|
+
return `${parts.join(' · ')} · 共 ${count} 篇`;
|
|
253
|
+
}
|
|
254
|
+
/** 页码列表 → 可读文案:`[2]` → `2`,`[4,5,6]` → `4–6`,`[2,5]` → `2、5`。 */
|
|
255
|
+
export function describePages(pages) {
|
|
256
|
+
const sorted = [...new Set(pages)].sort((a, b) => a - b);
|
|
257
|
+
if (sorted.length === 0)
|
|
258
|
+
return '';
|
|
259
|
+
const chunks = [];
|
|
260
|
+
let start = sorted[0];
|
|
261
|
+
let prev = sorted[0];
|
|
262
|
+
for (let index = 1; index < sorted.length; index += 1) {
|
|
263
|
+
const page = sorted[index];
|
|
264
|
+
if (page === prev + 1) {
|
|
265
|
+
prev = page;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
chunks.push(start === prev ? String(start) : `${start}–${prev}`);
|
|
269
|
+
start = page;
|
|
270
|
+
prev = page;
|
|
271
|
+
}
|
|
272
|
+
chunks.push(start === prev ? String(start) : `${start}–${prev}`);
|
|
273
|
+
return chunks.join('、');
|
|
274
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 任何新筛选条件都必须先加到这里,避免列表与统计出现两套过滤逻辑。
|
|
5
5
|
*/
|
|
6
|
-
import type { Paper, PdfStatus, Screening, SearchTask, TaskLanguage } from '../../search/types.js';
|
|
6
|
+
import type { Paper, PdfStatus, Screening, SearchTask, SortMode, TaskLanguage } from '../../search/types.js';
|
|
7
7
|
export interface PaperFilter {
|
|
8
8
|
source?: string;
|
|
9
9
|
yearFrom?: number;
|
|
@@ -35,6 +35,13 @@ export interface PaperFilter {
|
|
|
35
35
|
}
|
|
36
36
|
export declare function filterPapers(task: SearchTask, filter?: PaperFilter): Paper[];
|
|
37
37
|
export declare function screeningOf(task: SearchTask, paper: Paper): Screening;
|
|
38
|
+
/**
|
|
39
|
+
* 结果排序:`/results`、`/stats` 与模型侧的按页取数共用这一份口径。
|
|
40
|
+
*
|
|
41
|
+
* `relevance` 保持上游合并后的原序(不做二次排序);被引/年份缺失的排在最后,
|
|
42
|
+
* 与面板看到的顺序一致。原地排序,调用方传入副本。
|
|
43
|
+
*/
|
|
44
|
+
export declare function sortPapers(papers: Paper[], sort: SortMode | undefined): void;
|
|
38
45
|
/** 有效全文状态:没有人工/下载记录时,上游给的 PDF 直链视为可用。 */
|
|
39
46
|
export declare function pdfStatusOf(task: SearchTask, paper: Paper): PdfStatus;
|
|
40
47
|
export declare function matchesLanguage(paper: {
|
package/lib/host/tasks/filter.js
CHANGED
|
@@ -62,6 +62,20 @@ export function filterPapers(task, filter = {}) {
|
|
|
62
62
|
export function screeningOf(task, paper) {
|
|
63
63
|
return task.states[paper.paperId]?.screening ?? 'unread';
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* 结果排序:`/results`、`/stats` 与模型侧的按页取数共用这一份口径。
|
|
67
|
+
*
|
|
68
|
+
* `relevance` 保持上游合并后的原序(不做二次排序);被引/年份缺失的排在最后,
|
|
69
|
+
* 与面板看到的顺序一致。原地排序,调用方传入副本。
|
|
70
|
+
*/
|
|
71
|
+
export function sortPapers(papers, sort) {
|
|
72
|
+
if (sort === 'citations') {
|
|
73
|
+
papers.sort((a, b) => (b.citedBy ?? -1) - (a.citedBy ?? -1));
|
|
74
|
+
}
|
|
75
|
+
else if (sort === 'year') {
|
|
76
|
+
papers.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
65
79
|
/** 有效全文状态:没有人工/下载记录时,上游给的 PDF 直链视为可用。 */
|
|
66
80
|
export function pdfStatusOf(task, paper) {
|
|
67
81
|
const recorded = task.states[paper.paperId]?.pdfStatus;
|