dsh-plugin-litsearch 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +157 -2
- package/client/client.js +9036 -0
- package/cordis.patch.yml +10 -0
- package/lib/host/events.d.ts +19 -0
- package/lib/host/events.js +58 -0
- package/lib/host/export/bibtex.d.ts +3 -0
- package/lib/host/export/bibtex.js +42 -0
- package/lib/host/export/common.d.ts +18 -0
- package/lib/host/export/common.js +25 -0
- package/lib/host/export/csv.d.ts +8 -0
- package/lib/host/export/csv.js +68 -0
- package/lib/host/export/index.d.ts +5 -0
- package/lib/host/export/index.js +25 -0
- package/lib/host/export/markdown.d.ts +6 -0
- package/lib/host/export/markdown.js +120 -0
- package/lib/host/export/ris.d.ts +3 -0
- package/lib/host/export/ris.js +35 -0
- package/lib/host/paths.d.ts +43 -0
- package/lib/host/paths.js +128 -0
- package/lib/host/pdf/store.d.ts +57 -0
- package/lib/host/pdf/store.js +291 -0
- package/lib/host/router.d.ts +20 -0
- package/lib/host/router.js +852 -0
- package/lib/host/tasks/compare.d.ts +8 -0
- package/lib/host/tasks/compare.js +49 -0
- package/lib/host/tasks/filter.d.ts +42 -0
- package/lib/host/tasks/filter.js +100 -0
- package/lib/host/tasks/rounds.d.ts +28 -0
- package/lib/host/tasks/rounds.js +74 -0
- package/lib/host/tasks/runner.d.ts +29 -0
- package/lib/host/tasks/runner.js +208 -0
- package/lib/host/tasks/service.d.ts +171 -0
- package/lib/host/tasks/service.js +976 -0
- package/lib/host/tasks/states.d.ts +28 -0
- package/lib/host/tasks/states.js +223 -0
- package/lib/host/tasks/stats.d.ts +13 -0
- package/lib/host/tasks/stats.js +233 -0
- package/lib/host/tasks/store.d.ts +64 -0
- package/lib/host/tasks/store.js +280 -0
- package/lib/host/tasks/summary.d.ts +9 -0
- package/lib/host/tasks/summary.js +54 -0
- package/lib/host/tools.d.ts +47 -0
- package/lib/host/tools.js +1138 -0
- package/lib/host/zotero/connector.d.ts +46 -0
- package/lib/host/zotero/connector.js +263 -0
- package/lib/index.d.ts +107 -0
- package/lib/index.js +153 -0
- package/lib/search/aggregate.d.ts +17 -0
- package/lib/search/aggregate.js +60 -0
- package/lib/search/config.d.ts +96 -0
- package/lib/search/config.js +96 -0
- package/lib/search/errors.d.ts +22 -0
- package/lib/search/errors.js +24 -0
- package/lib/search/health.d.ts +13 -0
- package/lib/search/health.js +53 -0
- package/lib/search/http.d.ts +13 -0
- package/lib/search/http.js +81 -0
- package/lib/search/identity.d.ts +27 -0
- package/lib/search/identity.js +154 -0
- package/lib/search/normalize.d.ts +17 -0
- package/lib/search/normalize.js +47 -0
- package/lib/search/oa.d.ts +21 -0
- package/lib/search/oa.js +149 -0
- package/lib/search/providers/arxiv.d.ts +9 -0
- package/lib/search/providers/arxiv.js +99 -0
- package/lib/search/providers/crossref.d.ts +8 -0
- package/lib/search/providers/crossref.js +107 -0
- package/lib/search/providers/europepmc.d.ts +8 -0
- package/lib/search/providers/europepmc.js +105 -0
- package/lib/search/providers/index.d.ts +13 -0
- package/lib/search/providers/index.js +34 -0
- package/lib/search/providers/openalex.d.ts +8 -0
- package/lib/search/providers/openalex.js +109 -0
- package/lib/search/providers/pubmed.d.ts +10 -0
- package/lib/search/providers/pubmed.js +143 -0
- package/lib/search/providers/pubtator.d.ts +9 -0
- package/lib/search/providers/pubtator.js +79 -0
- package/lib/search/providers/semanticscholar.d.ts +9 -0
- package/lib/search/providers/semanticscholar.js +97 -0
- package/lib/search/pubtator-entities.d.ts +9 -0
- package/lib/search/pubtator-entities.js +19 -0
- package/lib/search/pubtator-parse.d.ts +3 -0
- package/lib/search/pubtator-parse.js +42 -0
- package/lib/search/query-adapter.d.ts +15 -0
- package/lib/search/query-adapter.js +69 -0
- package/lib/search/rate-limit.d.ts +31 -0
- package/lib/search/rate-limit.js +219 -0
- package/lib/search/types.d.ts +577 -0
- package/lib/search/types.js +15 -0
- package/lib/search/xml.d.ts +20 -0
- package/lib/search/xml.js +63 -0
- package/package.json +75 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 筛读台账:论文级状态(四态筛读 / 分组 / 必读顺序 / 备注 / 标签)与任务分组。
|
|
3
|
+
*
|
|
4
|
+
* 所有写入口(面板注解、模型 `literature_task annotate`、分组编辑)都先经过
|
|
5
|
+
* 这里的校验,确保状态结构稳定;`states` 以稳定的 `paperId` 为键,因此跨任务
|
|
6
|
+
* 继承与重跑都不会错位。
|
|
7
|
+
*/
|
|
8
|
+
import type { AnnotationInput, Paper, PaperState, PdfStatus, Screening, SearchTask, TaskGroup } from '../../search/types.js';
|
|
9
|
+
export declare const SCREENINGS: readonly Screening[];
|
|
10
|
+
export declare const PDF_STATUSES: readonly PdfStatus[];
|
|
11
|
+
export declare function isScreening(value: unknown): value is Screening;
|
|
12
|
+
export declare function isPdfStatus(value: unknown): value is PdfStatus;
|
|
13
|
+
export declare function emptyState(): PaperState;
|
|
14
|
+
/** 当前任务里每篇论文的有效状态(缺省为未读)。 */
|
|
15
|
+
export declare function stateOf(task: SearchTask, paperId: string): PaperState;
|
|
16
|
+
/** 按当前结果集统计四态数量;被删掉的结果不计入。 */
|
|
17
|
+
export declare function countScreening(papers: readonly Paper[], states: Readonly<Record<string, PaperState>>): Record<Screening, number>;
|
|
18
|
+
/** 校验并合并一批注解;返回更新后的 states 与命中条数。 */
|
|
19
|
+
export declare function applyAnnotations(task: SearchTask, inputs: readonly AnnotationInput[]): {
|
|
20
|
+
states: Record<string, PaperState>;
|
|
21
|
+
updated: number;
|
|
22
|
+
};
|
|
23
|
+
/** 校验分组列表:id 唯一、名称非空、顺序重排为连续整数。 */
|
|
24
|
+
export declare function normalizeGroups(values: readonly TaskGroup[] | undefined): TaskGroup[] | undefined;
|
|
25
|
+
/** 分组若仍有论文引用,删除前提醒调用方(调用方决定是否允许)。 */
|
|
26
|
+
export declare function groupUsage(task: SearchTask, groupId: string): number;
|
|
27
|
+
/** 按必读顺序、年份、标题排序某组的论文。 */
|
|
28
|
+
export declare function sortGroupPapers(task: SearchTask, papers: readonly Paper[]): Paper[];
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 筛读台账:论文级状态(四态筛读 / 分组 / 必读顺序 / 备注 / 标签)与任务分组。
|
|
3
|
+
*
|
|
4
|
+
* 所有写入口(面板注解、模型 `literature_task annotate`、分组编辑)都先经过
|
|
5
|
+
* 这里的校验,确保状态结构稳定;`states` 以稳定的 `paperId` 为键,因此跨任务
|
|
6
|
+
* 继承与重跑都不会错位。
|
|
7
|
+
*/
|
|
8
|
+
import { SearchError } from '../../search/errors.js';
|
|
9
|
+
const MAX_ANNOTATIONS = 50;
|
|
10
|
+
const MAX_GROUPS = 50;
|
|
11
|
+
const MAX_GROUP_NAME = 60;
|
|
12
|
+
const MAX_GROUP_DESCRIPTION = 500;
|
|
13
|
+
const MAX_NOTE = 4000;
|
|
14
|
+
const MAX_TAGS = 20;
|
|
15
|
+
const MAX_TAG = 40;
|
|
16
|
+
const MAX_PDF_ERROR = 300;
|
|
17
|
+
const MAX_PDF_FILE = 1024;
|
|
18
|
+
const MAX_SHORTLIST_ORDER = 100_000;
|
|
19
|
+
export const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
|
|
20
|
+
export const PDF_STATUSES = [
|
|
21
|
+
'unknown',
|
|
22
|
+
'available',
|
|
23
|
+
'downloaded',
|
|
24
|
+
'html-only',
|
|
25
|
+
'paywalled',
|
|
26
|
+
'blocked',
|
|
27
|
+
'failed',
|
|
28
|
+
];
|
|
29
|
+
export function isScreening(value) {
|
|
30
|
+
return typeof value === 'string' && SCREENINGS.includes(value);
|
|
31
|
+
}
|
|
32
|
+
export function isPdfStatus(value) {
|
|
33
|
+
return typeof value === 'string' && PDF_STATUSES.includes(value);
|
|
34
|
+
}
|
|
35
|
+
export function emptyState() {
|
|
36
|
+
return { screening: 'unread' };
|
|
37
|
+
}
|
|
38
|
+
/** 当前任务里每篇论文的有效状态(缺省为未读)。 */
|
|
39
|
+
export function stateOf(task, paperId) {
|
|
40
|
+
return task.states[paperId] ?? emptyState();
|
|
41
|
+
}
|
|
42
|
+
/** 按当前结果集统计四态数量;被删掉的结果不计入。 */
|
|
43
|
+
export function countScreening(papers, states) {
|
|
44
|
+
const counts = { unread: 0, read: 0, included: 0, excluded: 0 };
|
|
45
|
+
for (const paper of papers) {
|
|
46
|
+
counts[states[paper.paperId]?.screening ?? 'unread'] += 1;
|
|
47
|
+
}
|
|
48
|
+
return counts;
|
|
49
|
+
}
|
|
50
|
+
/** 校验并合并一批注解;返回更新后的 states 与命中条数。 */
|
|
51
|
+
export function applyAnnotations(task, inputs) {
|
|
52
|
+
if (inputs.length === 0)
|
|
53
|
+
throw new SearchError('bad_request', 'papers 不能为空');
|
|
54
|
+
if (inputs.length > MAX_ANNOTATIONS) {
|
|
55
|
+
throw new SearchError('bad_request', `一次最多注解 ${MAX_ANNOTATIONS} 篇`);
|
|
56
|
+
}
|
|
57
|
+
const known = new Set(task.result.papers.map((paper) => paper.paperId));
|
|
58
|
+
const groupIds = new Set(task.groups.map((group) => group.id));
|
|
59
|
+
const states = { ...task.states };
|
|
60
|
+
let updated = 0;
|
|
61
|
+
for (const input of inputs) {
|
|
62
|
+
const paperId = input.paperId.trim();
|
|
63
|
+
if (paperId === '')
|
|
64
|
+
throw new SearchError('bad_request', 'paperId 不能为空');
|
|
65
|
+
if (!known.has(paperId)) {
|
|
66
|
+
throw new SearchError('bad_request', `任务里没有这篇文献:${paperId}`);
|
|
67
|
+
}
|
|
68
|
+
const next = { ...(states[paperId] ?? emptyState()) };
|
|
69
|
+
let touched = false;
|
|
70
|
+
if (input.screening !== undefined) {
|
|
71
|
+
if (!isScreening(input.screening)) {
|
|
72
|
+
throw new SearchError('bad_request', `screening 只能是 ${SCREENINGS.join(' / ')}`);
|
|
73
|
+
}
|
|
74
|
+
next.screening = input.screening;
|
|
75
|
+
touched = true;
|
|
76
|
+
}
|
|
77
|
+
if (input.groupId !== undefined) {
|
|
78
|
+
if (input.groupId === null || input.groupId === '') {
|
|
79
|
+
delete next.groupId;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
if (!groupIds.has(input.groupId)) {
|
|
83
|
+
throw new SearchError('bad_request', `未知分组:${input.groupId}`);
|
|
84
|
+
}
|
|
85
|
+
next.groupId = input.groupId;
|
|
86
|
+
}
|
|
87
|
+
touched = true;
|
|
88
|
+
}
|
|
89
|
+
if (input.shortlistOrder !== undefined) {
|
|
90
|
+
if (input.shortlistOrder === null) {
|
|
91
|
+
delete next.shortlistOrder;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
if (!Number.isInteger(input.shortlistOrder) ||
|
|
95
|
+
input.shortlistOrder < 0 ||
|
|
96
|
+
input.shortlistOrder > MAX_SHORTLIST_ORDER) {
|
|
97
|
+
throw new SearchError('bad_request', `shortlistOrder 必须是 0..${MAX_SHORTLIST_ORDER} 的整数`);
|
|
98
|
+
}
|
|
99
|
+
next.shortlistOrder = input.shortlistOrder;
|
|
100
|
+
}
|
|
101
|
+
touched = true;
|
|
102
|
+
}
|
|
103
|
+
if (input.note !== undefined) {
|
|
104
|
+
if (typeof input.note !== 'string')
|
|
105
|
+
throw new SearchError('bad_request', 'note 必须是字符串');
|
|
106
|
+
const note = input.note.trim();
|
|
107
|
+
if (note === '')
|
|
108
|
+
delete next.note;
|
|
109
|
+
else
|
|
110
|
+
next.note = note.slice(0, MAX_NOTE);
|
|
111
|
+
touched = true;
|
|
112
|
+
}
|
|
113
|
+
if (input.tags !== undefined) {
|
|
114
|
+
if (!Array.isArray(input.tags))
|
|
115
|
+
throw new SearchError('bad_request', 'tags 必须是字符串数组');
|
|
116
|
+
const tags = normalizeTags(input.tags);
|
|
117
|
+
if (tags.length === 0)
|
|
118
|
+
delete next.tags;
|
|
119
|
+
else
|
|
120
|
+
next.tags = tags;
|
|
121
|
+
touched = true;
|
|
122
|
+
}
|
|
123
|
+
if (input.pdfStatus !== undefined) {
|
|
124
|
+
if (!isPdfStatus(input.pdfStatus)) {
|
|
125
|
+
throw new SearchError('bad_request', `pdfStatus 只能是 ${PDF_STATUSES.join(' / ')}`);
|
|
126
|
+
}
|
|
127
|
+
next.pdfStatus = input.pdfStatus;
|
|
128
|
+
touched = true;
|
|
129
|
+
}
|
|
130
|
+
if (input.pdfError !== undefined) {
|
|
131
|
+
if (typeof input.pdfError !== 'string') {
|
|
132
|
+
throw new SearchError('bad_request', 'pdfError 必须是字符串');
|
|
133
|
+
}
|
|
134
|
+
const reason = input.pdfError.trim();
|
|
135
|
+
if (reason === '')
|
|
136
|
+
delete next.pdfError;
|
|
137
|
+
else
|
|
138
|
+
next.pdfError = reason.slice(0, MAX_PDF_ERROR);
|
|
139
|
+
touched = true;
|
|
140
|
+
}
|
|
141
|
+
if (input.pdfFile !== undefined) {
|
|
142
|
+
if (typeof input.pdfFile !== 'string') {
|
|
143
|
+
throw new SearchError('bad_request', 'pdfFile 必须是字符串');
|
|
144
|
+
}
|
|
145
|
+
const file = input.pdfFile.trim();
|
|
146
|
+
if (file === '')
|
|
147
|
+
delete next.pdfFile;
|
|
148
|
+
else
|
|
149
|
+
next.pdfFile = file.slice(0, MAX_PDF_FILE);
|
|
150
|
+
touched = true;
|
|
151
|
+
}
|
|
152
|
+
if (!touched)
|
|
153
|
+
continue;
|
|
154
|
+
states[paperId] = next;
|
|
155
|
+
updated += 1;
|
|
156
|
+
}
|
|
157
|
+
return { states, updated };
|
|
158
|
+
}
|
|
159
|
+
/** 校验分组列表:id 唯一、名称非空、顺序重排为连续整数。 */
|
|
160
|
+
export function normalizeGroups(values) {
|
|
161
|
+
if (values === undefined)
|
|
162
|
+
return undefined;
|
|
163
|
+
if (!Array.isArray(values))
|
|
164
|
+
throw new SearchError('bad_request', 'groups 必须是数组');
|
|
165
|
+
if (values.length > MAX_GROUPS) {
|
|
166
|
+
throw new SearchError('bad_request', `分组最多 ${MAX_GROUPS} 个`);
|
|
167
|
+
}
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
const groups = [];
|
|
170
|
+
for (const raw of values) {
|
|
171
|
+
const id = raw.id.trim();
|
|
172
|
+
if (id === '' || id.length > 40) {
|
|
173
|
+
throw new SearchError('bad_request', '分组 id 必须是 1..40 个字符');
|
|
174
|
+
}
|
|
175
|
+
if (seen.has(id))
|
|
176
|
+
throw new SearchError('bad_request', `分组 id 重复:${id}`);
|
|
177
|
+
seen.add(id);
|
|
178
|
+
const name = raw.name.trim();
|
|
179
|
+
if (name === '')
|
|
180
|
+
throw new SearchError('bad_request', '分组名称不能为空');
|
|
181
|
+
const group = {
|
|
182
|
+
id,
|
|
183
|
+
name: name.slice(0, MAX_GROUP_NAME),
|
|
184
|
+
order: groups.length,
|
|
185
|
+
};
|
|
186
|
+
const description = raw.description?.trim() ?? '';
|
|
187
|
+
if (description !== '')
|
|
188
|
+
group.description = description.slice(0, MAX_GROUP_DESCRIPTION);
|
|
189
|
+
groups.push(group);
|
|
190
|
+
}
|
|
191
|
+
return groups;
|
|
192
|
+
}
|
|
193
|
+
/** 分组若仍有论文引用,删除前提醒调用方(调用方决定是否允许)。 */
|
|
194
|
+
export function groupUsage(task, groupId) {
|
|
195
|
+
return Object.values(task.states).filter((state) => state.groupId === groupId).length;
|
|
196
|
+
}
|
|
197
|
+
/** 按必读顺序、年份、标题排序某组的论文。 */
|
|
198
|
+
export function sortGroupPapers(task, papers) {
|
|
199
|
+
return [...papers].sort((a, b) => {
|
|
200
|
+
const left = stateOf(task, a.paperId).shortlistOrder;
|
|
201
|
+
const right = stateOf(task, b.paperId).shortlistOrder;
|
|
202
|
+
if (left !== undefined || right !== undefined) {
|
|
203
|
+
if (left === undefined)
|
|
204
|
+
return 1;
|
|
205
|
+
if (right === undefined)
|
|
206
|
+
return -1;
|
|
207
|
+
if (left !== right)
|
|
208
|
+
return left - right;
|
|
209
|
+
}
|
|
210
|
+
return (b.year ?? 0) - (a.year ?? 0) || a.title.localeCompare(b.title);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function normalizeTags(values) {
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
for (const raw of values) {
|
|
216
|
+
const tag = raw.trim().slice(0, MAX_TAG);
|
|
217
|
+
if (tag !== '')
|
|
218
|
+
seen.add(tag);
|
|
219
|
+
if (seen.size >= MAX_TAGS)
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
return [...seen];
|
|
223
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务概览统计。
|
|
3
|
+
*
|
|
4
|
+
* 只统计**已加载**的结果(任务最多 1500 篇,内存聚合毫秒级),并复用
|
|
5
|
+
* `filter.ts` 的筛选口径,保证列表与统计一致。结果按 `rev + 口径 + 筛选指纹`
|
|
6
|
+
* 缓存 30 秒,追加/重跑后 rev 变化自动失效。
|
|
7
|
+
*/
|
|
8
|
+
import type { SearchTask, TaskStatsReport } from '../../search/types.js';
|
|
9
|
+
import { type PaperFilter } from './filter.js';
|
|
10
|
+
export declare function computeStats(task: SearchTask, filter: PaperFilter, scope: 'task' | 'filtered', base?: {
|
|
11
|
+
paperIds: ReadonlySet<string>;
|
|
12
|
+
rev: number;
|
|
13
|
+
}): TaskStatsReport;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务概览统计。
|
|
3
|
+
*
|
|
4
|
+
* 只统计**已加载**的结果(任务最多 1500 篇,内存聚合毫秒级),并复用
|
|
5
|
+
* `filter.ts` 的筛选口径,保证列表与统计一致。结果按 `rev + 口径 + 筛选指纹`
|
|
6
|
+
* 缓存 30 秒,追加/重跑后 rev 变化自动失效。
|
|
7
|
+
*/
|
|
8
|
+
import { filterKey, filterPapers, pdfStatusOf, screeningOf } from './filter.js';
|
|
9
|
+
const CACHE_TTL_MS = 30_000;
|
|
10
|
+
const CACHE_MAX = 16;
|
|
11
|
+
const TOP_LIST = 15;
|
|
12
|
+
const TOP_PAPERS = 10;
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
export function computeStats(task, filter, scope, base) {
|
|
15
|
+
const key = `${task.id}:${task.rev}:${scope}:${filterKey(filter)}:${base?.rev ?? ''}`;
|
|
16
|
+
const cached = cache.get(key);
|
|
17
|
+
if (cached !== undefined && Date.now() - cached.at < CACHE_TTL_MS)
|
|
18
|
+
return cached.report;
|
|
19
|
+
const report = buildReport(task, filter, scope, base);
|
|
20
|
+
cache.set(key, { at: Date.now(), report });
|
|
21
|
+
if (cache.size > CACHE_MAX) {
|
|
22
|
+
const oldest = cache.keys().next().value;
|
|
23
|
+
if (oldest !== undefined)
|
|
24
|
+
cache.delete(oldest);
|
|
25
|
+
}
|
|
26
|
+
return report;
|
|
27
|
+
}
|
|
28
|
+
function buildReport(task, filter, scope, base) {
|
|
29
|
+
const all = task.result.papers;
|
|
30
|
+
const papers = scope === 'filtered' ? filterPapers(task, filter) : all;
|
|
31
|
+
const included = papers.filter((paper) => screeningOf(task, paper) === 'included').length;
|
|
32
|
+
const excluded = papers.filter((paper) => screeningOf(task, paper) === 'excluded').length;
|
|
33
|
+
const read = papers.filter((paper) => screeningOf(task, paper) === 'read').length;
|
|
34
|
+
const unread = papers.length - included - excluded - read;
|
|
35
|
+
const fullText = countFullText(task, papers);
|
|
36
|
+
return {
|
|
37
|
+
taskId: task.id,
|
|
38
|
+
rev: task.rev,
|
|
39
|
+
scope,
|
|
40
|
+
loaded: papers.length,
|
|
41
|
+
totalHint: all.length,
|
|
42
|
+
funnel: {
|
|
43
|
+
rawHits: task.stats.rawHits,
|
|
44
|
+
deduped: task.stats.deduped,
|
|
45
|
+
filtered: papers.length,
|
|
46
|
+
included,
|
|
47
|
+
excluded,
|
|
48
|
+
unread,
|
|
49
|
+
read,
|
|
50
|
+
},
|
|
51
|
+
bySource: buildSourceStats(task, papers),
|
|
52
|
+
byQuery: buildQueryStats(task, papers),
|
|
53
|
+
byYear: buildYearStats(task, papers),
|
|
54
|
+
byType: countBy(task, papers, (paper) => paper.type),
|
|
55
|
+
byLanguage: countBy(task, papers, (paper) => paper.language ?? ''),
|
|
56
|
+
topVenues: topBy(task, papers, (paper) => (paper.venue === '' ? [] : [paper.venue])),
|
|
57
|
+
topAuthors: topBy(task, papers, (paper) => paper.authors),
|
|
58
|
+
metrics: buildMetrics(papers, base),
|
|
59
|
+
screeningProgress: { included, excluded, pending: unread },
|
|
60
|
+
unavailableFullText: {
|
|
61
|
+
paywalled: fullText.paywalled,
|
|
62
|
+
blocked: fullText.blocked,
|
|
63
|
+
failed: fullText.failed,
|
|
64
|
+
},
|
|
65
|
+
fullText,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function countFullText(task, papers) {
|
|
69
|
+
const coverage = {
|
|
70
|
+
unknown: 0,
|
|
71
|
+
available: 0,
|
|
72
|
+
downloaded: 0,
|
|
73
|
+
htmlOnly: 0,
|
|
74
|
+
paywalled: 0,
|
|
75
|
+
blocked: 0,
|
|
76
|
+
failed: 0,
|
|
77
|
+
};
|
|
78
|
+
for (const paper of papers) {
|
|
79
|
+
const status = pdfStatusOf(task, paper);
|
|
80
|
+
if (status === 'html-only')
|
|
81
|
+
coverage.htmlOnly += 1;
|
|
82
|
+
else if (status === 'available')
|
|
83
|
+
coverage.available += 1;
|
|
84
|
+
else if (status === 'downloaded')
|
|
85
|
+
coverage.downloaded += 1;
|
|
86
|
+
else if (status === 'paywalled')
|
|
87
|
+
coverage.paywalled += 1;
|
|
88
|
+
else if (status === 'blocked')
|
|
89
|
+
coverage.blocked += 1;
|
|
90
|
+
else if (status === 'failed')
|
|
91
|
+
coverage.failed += 1;
|
|
92
|
+
else
|
|
93
|
+
coverage.unknown += 1;
|
|
94
|
+
}
|
|
95
|
+
return coverage;
|
|
96
|
+
}
|
|
97
|
+
function buildSourceStats(task, papers) {
|
|
98
|
+
const ids = new Set(task.conditions.sources);
|
|
99
|
+
for (const paper of papers) {
|
|
100
|
+
for (const hit of paper.hitSources)
|
|
101
|
+
ids.add(hit.source);
|
|
102
|
+
}
|
|
103
|
+
return task.conditions.sources
|
|
104
|
+
.filter((id) => ids.has(id))
|
|
105
|
+
.map((id) => {
|
|
106
|
+
const hits = papers.filter((paper) => paper.hitSources.some((hit) => hit.source === id));
|
|
107
|
+
return {
|
|
108
|
+
id,
|
|
109
|
+
hits: hits.length,
|
|
110
|
+
uniqueOnly: hits.filter((paper) => paper.hitSources.length === 1).length,
|
|
111
|
+
oa: hits.filter((paper) => paper.openAccess).length,
|
|
112
|
+
withPdf: hits.filter((paper) => paper.pdfUrl !== '').length,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function buildQueryStats(task, papers) {
|
|
117
|
+
const queryIds = task.conditions.queries.map((query) => query.id);
|
|
118
|
+
const overlap = new Map();
|
|
119
|
+
for (const id of queryIds) {
|
|
120
|
+
overlap.set(id, new Map(queryIds.filter((other) => other !== id).map((other) => [other, 0])));
|
|
121
|
+
}
|
|
122
|
+
for (const paper of papers) {
|
|
123
|
+
const matched = paper.matchedQueries.filter((id) => overlap.has(id));
|
|
124
|
+
for (const left of matched) {
|
|
125
|
+
for (const right of matched) {
|
|
126
|
+
if (left === right)
|
|
127
|
+
continue;
|
|
128
|
+
const row = overlap.get(left);
|
|
129
|
+
if (row === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
row.set(right, (row.get(right) ?? 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return task.conditions.queries.map((query) => {
|
|
136
|
+
const uniqueOnly = papers.filter((paper) => paper.matchedQueries.length === 1 && paper.matchedQueries[0] === query.id).length;
|
|
137
|
+
const row = overlap.get(query.id);
|
|
138
|
+
return {
|
|
139
|
+
queryId: query.id,
|
|
140
|
+
name: query.name,
|
|
141
|
+
hits: task.stats.perQuery[query.id] ?? 0,
|
|
142
|
+
uniqueOnly,
|
|
143
|
+
overlap: row === undefined ? {} : Object.fromEntries(row),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function buildYearStats(task, papers) {
|
|
148
|
+
const years = new Map();
|
|
149
|
+
for (const paper of papers) {
|
|
150
|
+
if (paper.year === null)
|
|
151
|
+
continue;
|
|
152
|
+
const entry = years.get(paper.year) ?? { count: 0, included: 0 };
|
|
153
|
+
entry.count += 1;
|
|
154
|
+
if (screeningOf(task, paper) === 'included')
|
|
155
|
+
entry.included += 1;
|
|
156
|
+
years.set(paper.year, entry);
|
|
157
|
+
}
|
|
158
|
+
return [...years.entries()]
|
|
159
|
+
.map(([year, entry]) => ({ year, ...entry }))
|
|
160
|
+
.sort((a, b) => a.year - b.year);
|
|
161
|
+
}
|
|
162
|
+
function countBy(task, papers, keyOf) {
|
|
163
|
+
const counts = new Map();
|
|
164
|
+
for (const paper of papers) {
|
|
165
|
+
const key = keyOf(paper).trim();
|
|
166
|
+
if (key === '')
|
|
167
|
+
continue;
|
|
168
|
+
const entry = counts.get(key) ?? { count: 0, included: 0 };
|
|
169
|
+
entry.count += 1;
|
|
170
|
+
if (screeningOf(task, paper) === 'included')
|
|
171
|
+
entry.included += 1;
|
|
172
|
+
counts.set(key, entry);
|
|
173
|
+
}
|
|
174
|
+
return [...counts.entries()]
|
|
175
|
+
.map(([key, entry]) => ({ key, ...entry }))
|
|
176
|
+
.sort((a, b) => b.count - a.count);
|
|
177
|
+
}
|
|
178
|
+
function topBy(task, papers, keysOf) {
|
|
179
|
+
const counts = new Map();
|
|
180
|
+
for (const paper of papers) {
|
|
181
|
+
const included = screeningOf(task, paper) === 'included';
|
|
182
|
+
for (const value of keysOf(paper)) {
|
|
183
|
+
const key = value.trim();
|
|
184
|
+
if (key === '')
|
|
185
|
+
continue;
|
|
186
|
+
const entry = counts.get(key) ?? { count: 0, included: 0 };
|
|
187
|
+
entry.count += 1;
|
|
188
|
+
if (included)
|
|
189
|
+
entry.included += 1;
|
|
190
|
+
counts.set(key, entry);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return [...counts.entries()]
|
|
194
|
+
.map(([key, entry]) => ({ key, ...entry }))
|
|
195
|
+
.sort((a, b) => b.count - a.count)
|
|
196
|
+
.slice(0, TOP_LIST);
|
|
197
|
+
}
|
|
198
|
+
function buildMetrics(papers, base) {
|
|
199
|
+
const total = papers.length;
|
|
200
|
+
const oa = papers.filter((paper) => paper.openAccess).length;
|
|
201
|
+
const pdf = papers.filter((paper) => paper.pdfUrl !== '').length;
|
|
202
|
+
const htmlOnly = papers.filter((paper) => paper.openAccess && paper.pdfUrl === '').length;
|
|
203
|
+
const citations = papers
|
|
204
|
+
.map((paper) => paper.citedBy)
|
|
205
|
+
.filter((value) => value !== null)
|
|
206
|
+
.sort((a, b) => a - b);
|
|
207
|
+
const median = citations.length === 0
|
|
208
|
+
? 0
|
|
209
|
+
: citations.length % 2 === 1
|
|
210
|
+
? citations[(citations.length - 1) / 2]
|
|
211
|
+
: Math.round((citations[citations.length / 2 - 1] + citations[citations.length / 2]) / 2);
|
|
212
|
+
return {
|
|
213
|
+
oaRate: total === 0 ? 0 : round(oa / total),
|
|
214
|
+
pdfRate: total === 0 ? 0 : round(pdf / total),
|
|
215
|
+
htmlOnlyRate: total === 0 ? 0 : round(htmlOnly / total),
|
|
216
|
+
medianCitations: median,
|
|
217
|
+
topCited: [...papers]
|
|
218
|
+
.filter((paper) => paper.citedBy !== null)
|
|
219
|
+
.sort((a, b) => (b.citedBy ?? 0) - (a.citedBy ?? 0))
|
|
220
|
+
.slice(0, TOP_PAPERS)
|
|
221
|
+
.map((paper) => paper.paperId),
|
|
222
|
+
latest: [...papers]
|
|
223
|
+
.sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
|
|
224
|
+
.slice(0, TOP_PAPERS)
|
|
225
|
+
.map((paper) => paper.paperId),
|
|
226
|
+
...(base === undefined
|
|
227
|
+
? {}
|
|
228
|
+
: { newVsBase: papers.filter((paper) => !base.paperIds.has(paper.paperId)).length }),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function round(value) {
|
|
232
|
+
return Math.round(value * 1000) / 1000;
|
|
233
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务持久化:`<root>/tasks/<id>.json` + `tasks/index.json`。
|
|
3
|
+
*
|
|
4
|
+
* - 数据根目录为 `$DSH_HOME/storages/dsh-plugin-litsearch`(见 `host/paths.ts`),
|
|
5
|
+
* 加载时若新目录为空而旧目录有任务,会一次性复制过来;显式传入 root 时不迁移;
|
|
6
|
+
* - 写盘全部串行执行,先写临时文件再 rename,避免半截 JSON;
|
|
7
|
+
* - 内存里只保留摘要,任务正文按需从磁盘读;
|
|
8
|
+
* - 落盘时摘要截到 {@link MAX_ABSTRACT_CHARS} 字符,任务数按保留策略裁剪。
|
|
9
|
+
*/
|
|
10
|
+
import type { SearchTask, TaskSummary } from '../../search/types.js';
|
|
11
|
+
export interface RetentionOptions {
|
|
12
|
+
retentionDays: number;
|
|
13
|
+
retentionCount: number;
|
|
14
|
+
}
|
|
15
|
+
/** 「当前任务」快照:最近一次使用或打开的任务。 */
|
|
16
|
+
export interface CurrentTaskSnapshot {
|
|
17
|
+
version: number;
|
|
18
|
+
current?: {
|
|
19
|
+
taskId: string;
|
|
20
|
+
at: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare class TaskStore {
|
|
24
|
+
root: string;
|
|
25
|
+
private tasksDir;
|
|
26
|
+
private indexFile;
|
|
27
|
+
private currentFile;
|
|
28
|
+
private readonly summaries;
|
|
29
|
+
private readonly cache;
|
|
30
|
+
private chain;
|
|
31
|
+
private retention;
|
|
32
|
+
/** 只有使用默认(平台统一)目录时才做旧数据迁移;显式 root 多用于测试。 */
|
|
33
|
+
private readonly migrateOnLoad;
|
|
34
|
+
/** 迁移过来的任务文件必须重建索引,避免沿用旧目录里那份空 index.json。 */
|
|
35
|
+
private forceRebuildIndex;
|
|
36
|
+
constructor(root?: string);
|
|
37
|
+
load(retention: RetentionOptions): Promise<void>;
|
|
38
|
+
list(): TaskSummary[];
|
|
39
|
+
summary(id: string): TaskSummary | undefined;
|
|
40
|
+
/** 同步取已缓存任务;未加载时返回 undefined。 */
|
|
41
|
+
cached(id: string): SearchTask | undefined;
|
|
42
|
+
loadTask(id: string): Promise<SearchTask | undefined>;
|
|
43
|
+
put(task: SearchTask): void;
|
|
44
|
+
save(task: SearchTask): Promise<void>;
|
|
45
|
+
remove(id: string): Promise<void>;
|
|
46
|
+
flush(): Promise<void>;
|
|
47
|
+
/** 读取「当前任务」快照;文件缺失或损坏时返回 null(由调用方自举)。 */
|
|
48
|
+
loadCurrent(): Promise<CurrentTaskSnapshot | null>;
|
|
49
|
+
saveCurrent(snapshot: CurrentTaskSnapshot): Promise<void>;
|
|
50
|
+
/** 手动清理:删除所有未置顶任务,返回条数与释放的字节数。 */
|
|
51
|
+
cleanupUnpinned(): Promise<{
|
|
52
|
+
removed: number;
|
|
53
|
+
freedBytes: number;
|
|
54
|
+
}>;
|
|
55
|
+
/** 迁移回退时切回旧目录读取,所有派生路径(含设置页展示)同步更新。 */
|
|
56
|
+
private rebind;
|
|
57
|
+
private taskFile;
|
|
58
|
+
private readIndex;
|
|
59
|
+
/** 索引缺失/损坏时按任务文件重建(只读头部,不长期占内存)。 */
|
|
60
|
+
private rebuildIndex;
|
|
61
|
+
private prune;
|
|
62
|
+
private writeIndex;
|
|
63
|
+
private enqueue;
|
|
64
|
+
}
|