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
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# dsh bundle patch:把本插件插入 profile 层栈。
|
|
2
|
+
#
|
|
3
|
+
# - 模型侧工具由本插件自持:直接在 Host 进程内调用免费文献 API
|
|
4
|
+
# (OpenAlex、Crossref…),再以 `literature_*` 名称注册进 dsh 工具表。
|
|
5
|
+
# 因此不需要插入任何 MCP client 行。
|
|
6
|
+
# - 人侧检索界面由 Client 半注册右侧栏 tab,并通过本插件在宿主 web server
|
|
7
|
+
# 上注册的 `/dsh-litsearch/api/*` 端点读写任务。
|
|
8
|
+
- insert:
|
|
9
|
+
- id: dsh-plugin-litsearch
|
|
10
|
+
name: dsh-plugin-litsearch
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务变更事件日志。
|
|
3
|
+
*
|
|
4
|
+
* Host→Client 没有原生广播通道,因此这里只维护一份「最近一次状态 + 单调递增
|
|
5
|
+
* 的 globalRev」日志;客户端按 `sinceRev` 轮询拿增量。globalRev 只在宿主进程内
|
|
6
|
+
* 单调递增,重启后归零 —— 客户端发现 `sinceRev` 比服务端大就以 `reset` 重新同步。
|
|
7
|
+
*/
|
|
8
|
+
import type { EventsPage, TaskSummary } from '../search/types.js';
|
|
9
|
+
export declare class TaskEvents {
|
|
10
|
+
private rev;
|
|
11
|
+
private readonly entries;
|
|
12
|
+
private focus;
|
|
13
|
+
get currentRev(): number;
|
|
14
|
+
publish(summary: TaskSummary): void;
|
|
15
|
+
/** 请求客户端把某个任务 tab 打开/切到前台(面板关着时重新打开)。 */
|
|
16
|
+
requestFocus(taskId: string, title: string, sessionId?: string): void;
|
|
17
|
+
remove(id: string): void;
|
|
18
|
+
since(sinceRev: number): EventsPage;
|
|
19
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务变更事件日志。
|
|
3
|
+
*
|
|
4
|
+
* Host→Client 没有原生广播通道,因此这里只维护一份「最近一次状态 + 单调递增
|
|
5
|
+
* 的 globalRev」日志;客户端按 `sinceRev` 轮询拿增量。globalRev 只在宿主进程内
|
|
6
|
+
* 单调递增,重启后归零 —— 客户端发现 `sinceRev` 比服务端大就以 `reset` 重新同步。
|
|
7
|
+
*/
|
|
8
|
+
export class TaskEvents {
|
|
9
|
+
rev = 0;
|
|
10
|
+
entries = new Map();
|
|
11
|
+
focus;
|
|
12
|
+
get currentRev() {
|
|
13
|
+
return this.rev;
|
|
14
|
+
}
|
|
15
|
+
publish(summary) {
|
|
16
|
+
this.rev += 1;
|
|
17
|
+
this.entries.set(summary.id, { globalRev: this.rev, summary });
|
|
18
|
+
}
|
|
19
|
+
/** 请求客户端把某个任务 tab 打开/切到前台(面板关着时重新打开)。 */
|
|
20
|
+
requestFocus(taskId, title, sessionId) {
|
|
21
|
+
this.rev += 1;
|
|
22
|
+
this.focus = {
|
|
23
|
+
globalRev: this.rev,
|
|
24
|
+
taskId,
|
|
25
|
+
title,
|
|
26
|
+
at: Date.now(),
|
|
27
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
remove(id) {
|
|
31
|
+
this.entries.delete(id);
|
|
32
|
+
if (this.focus?.taskId === id)
|
|
33
|
+
this.focus = undefined;
|
|
34
|
+
}
|
|
35
|
+
since(sinceRev) {
|
|
36
|
+
if (!Number.isFinite(sinceRev) || sinceRev < 0) {
|
|
37
|
+
return { rev: this.rev, reset: true, tasks: [] };
|
|
38
|
+
}
|
|
39
|
+
if (sinceRev > this.rev) {
|
|
40
|
+
return { rev: this.rev, reset: true, tasks: [] };
|
|
41
|
+
}
|
|
42
|
+
const tasks = [...this.entries.values()]
|
|
43
|
+
.filter((entry) => entry.globalRev > sinceRev)
|
|
44
|
+
.sort((a, b) => a.globalRev - b.globalRev)
|
|
45
|
+
.map((entry) => entry.summary);
|
|
46
|
+
const focus = this.focus !== undefined && this.focus.globalRev > sinceRev
|
|
47
|
+
? {
|
|
48
|
+
taskId: this.focus.taskId,
|
|
49
|
+
title: this.focus.title,
|
|
50
|
+
at: this.focus.at,
|
|
51
|
+
...(this.focus.sessionId === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { sessionId: this.focus.sessionId }),
|
|
54
|
+
}
|
|
55
|
+
: undefined;
|
|
56
|
+
return { rev: this.rev, reset: false, tasks, ...(focus === undefined ? {} : { focus }) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** BibTeX 导出。 */
|
|
2
|
+
import { groupName, paperNote } from './common.js';
|
|
3
|
+
import { stateOf } from '../tasks/states.js';
|
|
4
|
+
export function renderBibtex(context) {
|
|
5
|
+
return `${context.papers
|
|
6
|
+
.map((paper) => {
|
|
7
|
+
const state = stateOf(context.task, paper.paperId);
|
|
8
|
+
const fields = [];
|
|
9
|
+
fields.push(['title', paper.title]);
|
|
10
|
+
if (paper.authors.length > 0)
|
|
11
|
+
fields.push(['author', paper.authors.join(' and ')]);
|
|
12
|
+
if (paper.year !== null)
|
|
13
|
+
fields.push(['year', String(paper.year)]);
|
|
14
|
+
if (paper.venue !== '')
|
|
15
|
+
fields.push(['journal', paper.venue]);
|
|
16
|
+
if (paper.doi !== '')
|
|
17
|
+
fields.push(['doi', paper.doi]);
|
|
18
|
+
if (paper.url !== '')
|
|
19
|
+
fields.push(['url', paper.url]);
|
|
20
|
+
if (paper.abstract !== '')
|
|
21
|
+
fields.push(['abstract', paper.abstract]);
|
|
22
|
+
const group = groupName(context.task, state.groupId);
|
|
23
|
+
if (group !== '')
|
|
24
|
+
fields.push(['keywords', group]);
|
|
25
|
+
const note = paperNote(context.task, paper);
|
|
26
|
+
if (note !== '')
|
|
27
|
+
fields.push(['note', note]);
|
|
28
|
+
const body = fields
|
|
29
|
+
.map(([name, value]) => ` ${name} = {${escapeBibtex(value)}}`)
|
|
30
|
+
.join(',\n');
|
|
31
|
+
return `@article{${paper.paperId},\n${body}\n}`;
|
|
32
|
+
})
|
|
33
|
+
.join('\n\n')}\n`;
|
|
34
|
+
}
|
|
35
|
+
function escapeBibtex(value) {
|
|
36
|
+
return value
|
|
37
|
+
.replace(/\\/gu, '\\\\')
|
|
38
|
+
.replace(/[{}]/gu, (match) => `\\${match}`)
|
|
39
|
+
.replace(/[&%$#_]/gu, (match) => `\\${match}`)
|
|
40
|
+
.replace(/~/gu, '\\textasciitilde{}')
|
|
41
|
+
.replace(/\^/gu, '\\textasciicircum{}');
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导出共用上下文:把任务、待导出论文与可选的对比基准打包给各格式渲染器。
|
|
3
|
+
*/
|
|
4
|
+
import type { Paper, SearchTask } from '../../search/types.js';
|
|
5
|
+
export interface ExportContext {
|
|
6
|
+
task: SearchTask;
|
|
7
|
+
papers: Paper[];
|
|
8
|
+
/** `markdown` 导出用于生成「与对比任务差异」区块。 */
|
|
9
|
+
base?: SearchTask;
|
|
10
|
+
generatedAt: Date;
|
|
11
|
+
}
|
|
12
|
+
export declare function queryName(task: SearchTask, queryId: string): string;
|
|
13
|
+
export declare function groupName(task: SearchTask, groupId: string | undefined): string;
|
|
14
|
+
export declare function paperNote(task: SearchTask, paper: Paper): string;
|
|
15
|
+
export declare function paperTags(task: SearchTask, paper: Paper): string[];
|
|
16
|
+
export declare function isoDate(date: Date): string;
|
|
17
|
+
/** 文件名用的安全任务 id(导出统一按 id 命名,避免标题里的路径字符)。 */
|
|
18
|
+
export declare function exportStem(task: SearchTask, date: Date): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导出共用上下文:把任务、待导出论文与可选的对比基准打包给各格式渲染器。
|
|
3
|
+
*/
|
|
4
|
+
import { stateOf } from '../tasks/states.js';
|
|
5
|
+
export function queryName(task, queryId) {
|
|
6
|
+
return task.conditions.queries.find((query) => query.id === queryId)?.name ?? queryId;
|
|
7
|
+
}
|
|
8
|
+
export function groupName(task, groupId) {
|
|
9
|
+
if (groupId === undefined)
|
|
10
|
+
return '';
|
|
11
|
+
return task.groups.find((group) => group.id === groupId)?.name ?? '';
|
|
12
|
+
}
|
|
13
|
+
export function paperNote(task, paper) {
|
|
14
|
+
return stateOf(task, paper.paperId).note ?? '';
|
|
15
|
+
}
|
|
16
|
+
export function paperTags(task, paper) {
|
|
17
|
+
return stateOf(task, paper.paperId).tags ?? [];
|
|
18
|
+
}
|
|
19
|
+
export function isoDate(date) {
|
|
20
|
+
return date.toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
/** 文件名用的安全任务 id(导出统一按 id 命名,避免标题里的路径字符)。 */
|
|
23
|
+
export function exportStem(task, date) {
|
|
24
|
+
return `litsearch-${task.id}-${isoDate(date)}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全量筛读台账 CSV。
|
|
3
|
+
*
|
|
4
|
+
* 列顺序与第一版方案一致,后续全文能力只需填 `pdfStatus` / `pdfError`,不改列。
|
|
5
|
+
* 带 UTF-8 BOM,方便 Excel 直接打开中文。
|
|
6
|
+
*/
|
|
7
|
+
import { groupName, paperNote, queryName } from './common.js';
|
|
8
|
+
import { pdfStatusOf } from '../tasks/filter.js';
|
|
9
|
+
import { stateOf } from '../tasks/states.js';
|
|
10
|
+
const COLUMNS = [
|
|
11
|
+
'paperId',
|
|
12
|
+
'title',
|
|
13
|
+
'authors',
|
|
14
|
+
'year',
|
|
15
|
+
'venue',
|
|
16
|
+
'doi',
|
|
17
|
+
'pmid',
|
|
18
|
+
'pmcid',
|
|
19
|
+
'arxivId',
|
|
20
|
+
'sources',
|
|
21
|
+
'matchedQueries',
|
|
22
|
+
'citedBy',
|
|
23
|
+
'openAccess',
|
|
24
|
+
'language',
|
|
25
|
+
'screening',
|
|
26
|
+
'group',
|
|
27
|
+
'shortlistOrder',
|
|
28
|
+
'pdfStatus',
|
|
29
|
+
'pdfError',
|
|
30
|
+
'note',
|
|
31
|
+
];
|
|
32
|
+
export function renderCsv(context) {
|
|
33
|
+
const { task, papers } = context;
|
|
34
|
+
const lines = [COLUMNS.join(',')];
|
|
35
|
+
for (const paper of papers) {
|
|
36
|
+
const state = stateOf(task, paper.paperId);
|
|
37
|
+
lines.push([
|
|
38
|
+
paper.paperId,
|
|
39
|
+
paper.title,
|
|
40
|
+
paper.authors.join('; '),
|
|
41
|
+
paper.year === null ? '' : String(paper.year),
|
|
42
|
+
paper.venue,
|
|
43
|
+
paper.doi,
|
|
44
|
+
paper.ids.pmid,
|
|
45
|
+
paper.ids.pmcid,
|
|
46
|
+
paper.ids.arxivId,
|
|
47
|
+
paper.hitSources.map((hit) => hit.source).join(';'),
|
|
48
|
+
paper.matchedQueries.map((id) => queryName(task, id)).join(';'),
|
|
49
|
+
paper.citedBy === null ? '' : String(paper.citedBy),
|
|
50
|
+
paper.openAccess ? '1' : '0',
|
|
51
|
+
paper.language ?? '',
|
|
52
|
+
state.screening,
|
|
53
|
+
groupName(task, state.groupId),
|
|
54
|
+
state.shortlistOrder === undefined ? '' : String(state.shortlistOrder),
|
|
55
|
+
pdfStatusOf(task, paper),
|
|
56
|
+
state.pdfError ?? '',
|
|
57
|
+
paperNote(task, paper),
|
|
58
|
+
]
|
|
59
|
+
.map(csvCell)
|
|
60
|
+
.join(','));
|
|
61
|
+
}
|
|
62
|
+
return `\uFEFF${lines.join('\r\n')}\r\n`;
|
|
63
|
+
}
|
|
64
|
+
function csvCell(value) {
|
|
65
|
+
if (!/[",\r\n]/u.test(value))
|
|
66
|
+
return value;
|
|
67
|
+
return `"${value.replace(/"/gu, '""')}"`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** 导出格式分发:把同一个上下文渲染成 CSV / RIS / BibTeX / Markdown。 */
|
|
2
|
+
import type { ExportFile, ExportFormat } from '../../search/types.js';
|
|
3
|
+
import type { ExportContext } from './common.js';
|
|
4
|
+
export type { ExportContext } from './common.js';
|
|
5
|
+
export declare function buildExport(context: ExportContext, format: ExportFormat): ExportFile;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** 导出格式分发:把同一个上下文渲染成 CSV / RIS / BibTeX / Markdown。 */
|
|
2
|
+
import { renderBibtex } from './bibtex.js';
|
|
3
|
+
import { exportStem } from './common.js';
|
|
4
|
+
import { renderCsv } from './csv.js';
|
|
5
|
+
import { renderMarkdown } from './markdown.js';
|
|
6
|
+
import { renderRis } from './ris.js';
|
|
7
|
+
const MIME = {
|
|
8
|
+
csv: 'text/csv; charset=utf-8',
|
|
9
|
+
ris: 'application/x-research-info-systems; charset=utf-8',
|
|
10
|
+
bibtex: 'application/x-bibtex; charset=utf-8',
|
|
11
|
+
md: 'text/markdown; charset=utf-8',
|
|
12
|
+
};
|
|
13
|
+
export function buildExport(context, format) {
|
|
14
|
+
const stem = exportStem(context.task, context.generatedAt);
|
|
15
|
+
switch (format) {
|
|
16
|
+
case 'csv':
|
|
17
|
+
return { filename: `${stem}.csv`, mime: MIME.csv, content: renderCsv(context) };
|
|
18
|
+
case 'ris':
|
|
19
|
+
return { filename: `${stem}.ris`, mime: MIME.ris, content: renderRis(context) };
|
|
20
|
+
case 'bibtex':
|
|
21
|
+
return { filename: `${stem}.bib`, mime: MIME.bibtex, content: renderBibtex(context) };
|
|
22
|
+
default:
|
|
23
|
+
return { filename: `${stem}.md`, mime: MIME.md, content: renderMarkdown(context) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown 检索报告:检索式组(可复现)、规模漏斗、分组阅读清单、必读路线、
|
|
3
|
+
* 对比差异与研究笔记。结构对齐两份真实产出。
|
|
4
|
+
*/
|
|
5
|
+
import { compareTasks } from '../tasks/compare.js';
|
|
6
|
+
import { sortGroupPapers, stateOf } from '../tasks/states.js';
|
|
7
|
+
import { isoDate } from './common.js';
|
|
8
|
+
const TOP_COMPARE = 30;
|
|
9
|
+
const TOP_ROUTE = 30;
|
|
10
|
+
export function renderMarkdown(context) {
|
|
11
|
+
const { task, papers, base, generatedAt } = context;
|
|
12
|
+
const lines = [];
|
|
13
|
+
lines.push(`# ${task.title}`, '');
|
|
14
|
+
lines.push(`- 任务 id:${task.id}`);
|
|
15
|
+
lines.push(`- 检索日期:${isoDate(new Date(task.result.fetchedAt ?? task.createdAt))}`);
|
|
16
|
+
lines.push(`- 报告生成:${isoDate(generatedAt)}`);
|
|
17
|
+
lines.push(`- 数据源:${task.conditions.sources.join('、')}`);
|
|
18
|
+
lines.push(`- 规模:原始命中 ${task.stats.rawHits} 条,去重 ${task.stats.deduped} 篇,已加载 ${task.result.papers.length} 篇`);
|
|
19
|
+
const counts = countStates(context);
|
|
20
|
+
lines.push(`- 筛读:纳入 ${counts.included} 篇,排除 ${counts.excluded} 篇,已读 ${counts.read} 篇,未读 ${counts.unread} 篇`);
|
|
21
|
+
if (base !== undefined) {
|
|
22
|
+
const diff = compareTasks(task, base);
|
|
23
|
+
lines.push(`- 对比「${base.title}」:新增 ${diff.counts.added} 篇,共有 ${diff.counts.common} 篇,缺失 ${diff.counts.missing} 篇`);
|
|
24
|
+
}
|
|
25
|
+
lines.push('');
|
|
26
|
+
lines.push('## 检索式组(可复现)', '');
|
|
27
|
+
task.conditions.queries.forEach((query, index) => {
|
|
28
|
+
lines.push(`${index + 1}. **${query.name}**:\`${query.text}\` — 原始命中 ${task.stats.perQuery[query.id] ?? 0} 条`);
|
|
29
|
+
const overrides = Object.entries(query.perSource ?? {});
|
|
30
|
+
if (overrides.length > 0) {
|
|
31
|
+
lines.push(` - 单源覆写:${overrides.map(([source, text]) => `${source}=\`${text}\``).join(';')}`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push('## 分组阅读清单', '');
|
|
36
|
+
for (const group of task.groups) {
|
|
37
|
+
const items = papers.filter((paper) => stateOf(task, paper.paperId).groupId === group.id);
|
|
38
|
+
lines.push(`### ${group.name}(${items.length} 篇)`, '');
|
|
39
|
+
if (group.description !== undefined && group.description !== '') {
|
|
40
|
+
lines.push(group.description, '');
|
|
41
|
+
}
|
|
42
|
+
if (items.length === 0) {
|
|
43
|
+
lines.push('_(暂无文献)_', '');
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
lines.push('| # | 文献 | 年份 / 出处 | 为什么读 | 状态 |');
|
|
47
|
+
lines.push('|---:|---|---|---|---|');
|
|
48
|
+
sortGroupPapers(task, items).forEach((paper, index) => {
|
|
49
|
+
lines.push(`| ${index + 1} | ${paperLabel(paper)} | ${metaLabel(paper)} | ${cell(stateOf(task, paper.paperId).note ?? '')} | ${stateOf(task, paper.paperId).screening} |`);
|
|
50
|
+
});
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const ungrouped = papers.filter((paper) => stateOf(task, paper.paperId).groupId === undefined);
|
|
55
|
+
if (ungrouped.length > 0) {
|
|
56
|
+
lines.push(`### 未分组(${ungrouped.length} 篇)`, '');
|
|
57
|
+
lines.push('| # | 文献 | 年份 / 出处 | 为什么读 | 状态 |');
|
|
58
|
+
lines.push('|---:|---|---|---|---|');
|
|
59
|
+
ungrouped.slice(0, 120).forEach((paper, index) => {
|
|
60
|
+
lines.push(`| ${index + 1} | ${paperLabel(paper)} | ${metaLabel(paper)} | ${cell(stateOf(task, paper.paperId).note ?? '')} | ${stateOf(task, paper.paperId).screening} |`);
|
|
61
|
+
});
|
|
62
|
+
if (ungrouped.length > 120)
|
|
63
|
+
lines.push('', `_(仅列出前 120 篇,完整清单见 CSV)_`);
|
|
64
|
+
lines.push('');
|
|
65
|
+
}
|
|
66
|
+
const route = papers
|
|
67
|
+
.filter((paper) => stateOf(task, paper.paperId).shortlistOrder !== undefined)
|
|
68
|
+
.sort((a, b) => (stateOf(task, a.paperId).shortlistOrder ?? 0) -
|
|
69
|
+
(stateOf(task, b.paperId).shortlistOrder ?? 0))
|
|
70
|
+
.slice(0, TOP_ROUTE);
|
|
71
|
+
lines.push('## 必读阅读路线', '');
|
|
72
|
+
if (route.length === 0) {
|
|
73
|
+
lines.push('_(尚未标记必读顺序)_', '');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
route.forEach((paper, index) => {
|
|
77
|
+
const note = stateOf(task, paper.paperId).note;
|
|
78
|
+
lines.push(`${index + 1}. ${paperLabel(paper)}${note === undefined ? '' : ` — ${note}`}`);
|
|
79
|
+
});
|
|
80
|
+
lines.push('');
|
|
81
|
+
}
|
|
82
|
+
lines.push('## 全文获取情况', '');
|
|
83
|
+
const oa = papers.filter((paper) => paper.openAccess).length;
|
|
84
|
+
const pdf = papers.filter((paper) => paper.pdfUrl !== '').length;
|
|
85
|
+
lines.push(`- 开放获取 ${oa} 篇,PDF 直链 ${pdf} 篇,其余 ${papers.length - oa} 篇需自行获取。`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
if (base !== undefined) {
|
|
88
|
+
const diff = compareTasks(task, base);
|
|
89
|
+
lines.push(`## 与对比任务差异`, '');
|
|
90
|
+
lines.push(`基准任务:${base.title}(${diff.counts.baseTotal} 篇)。新增 ${diff.counts.added} 篇,共有 ${diff.counts.common} 篇,缺失 ${diff.counts.missing} 篇。`, '');
|
|
91
|
+
diff.added.slice(0, TOP_COMPARE).forEach((paper, index) => {
|
|
92
|
+
lines.push(`${index + 1}. ${paper.title}${paper.year === null ? '' : `(${paper.year})`}`);
|
|
93
|
+
});
|
|
94
|
+
if (diff.added.length > TOP_COMPARE) {
|
|
95
|
+
lines.push('', `_(仅列出前 ${TOP_COMPARE} 篇新增)_`);
|
|
96
|
+
}
|
|
97
|
+
lines.push('');
|
|
98
|
+
}
|
|
99
|
+
lines.push('## 研究笔记', '');
|
|
100
|
+
lines.push(task.notes.trim() === '' ? '_(暂无)_' : task.notes.trim(), '');
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
function countStates(context) {
|
|
104
|
+
const counts = { unread: 0, read: 0, included: 0, excluded: 0 };
|
|
105
|
+
for (const paper of context.papers) {
|
|
106
|
+
counts[stateOf(context.task, paper.paperId).screening] += 1;
|
|
107
|
+
}
|
|
108
|
+
return counts;
|
|
109
|
+
}
|
|
110
|
+
function paperLabel(paper) {
|
|
111
|
+
const text = cell(paper.title);
|
|
112
|
+
return paper.doi === '' ? text : `[${text}](https://doi.org/${paper.doi})`;
|
|
113
|
+
}
|
|
114
|
+
function metaLabel(paper) {
|
|
115
|
+
const year = paper.year === null ? '' : String(paper.year);
|
|
116
|
+
return cell([year, paper.venue].filter((part) => part !== '').join(' / '));
|
|
117
|
+
}
|
|
118
|
+
function cell(value) {
|
|
119
|
+
return value.trim().replace(/\|/gu, '\\|').replace(/\r?\n/gu, ' ');
|
|
120
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** RIS 导出(供 Zotero / EndNote 导入)。 */
|
|
2
|
+
import { groupName, paperNote, paperTags } from './common.js';
|
|
3
|
+
import { stateOf } from '../tasks/states.js';
|
|
4
|
+
export function renderRis(context) {
|
|
5
|
+
const blocks = context.papers.map((paper) => {
|
|
6
|
+
const state = stateOf(context.task, paper.paperId);
|
|
7
|
+
const lines = ['TY - JOUR'];
|
|
8
|
+
push(lines, 'TI', paper.title);
|
|
9
|
+
for (const author of paper.authors)
|
|
10
|
+
push(lines, 'AU', author);
|
|
11
|
+
if (paper.year !== null)
|
|
12
|
+
push(lines, 'PY', String(paper.year));
|
|
13
|
+
push(lines, 'JO', paper.venue);
|
|
14
|
+
push(lines, 'DO', paper.doi);
|
|
15
|
+
push(lines, 'UR', paper.pdfUrl !== '' ? paper.pdfUrl : paper.url);
|
|
16
|
+
push(lines, 'AB', paper.abstract);
|
|
17
|
+
push(lines, 'AN', paper.paperId);
|
|
18
|
+
const group = groupName(context.task, state.groupId);
|
|
19
|
+
if (group !== '')
|
|
20
|
+
push(lines, 'KW', group);
|
|
21
|
+
for (const tag of paperTags(context.task, paper))
|
|
22
|
+
push(lines, 'KW', tag);
|
|
23
|
+
push(lines, 'N1', paperNote(context.task, paper));
|
|
24
|
+
if (state.screening !== 'unread')
|
|
25
|
+
push(lines, 'N1', `screening: ${state.screening}`);
|
|
26
|
+
lines.push('ER - ');
|
|
27
|
+
return lines.join('\r\n');
|
|
28
|
+
});
|
|
29
|
+
return `${blocks.join('\r\n\r\n')}\r\n`;
|
|
30
|
+
}
|
|
31
|
+
function push(lines, tag, value) {
|
|
32
|
+
if (value.trim() === '')
|
|
33
|
+
return;
|
|
34
|
+
lines.push(`${tag} - ${value.replace(/\r?\n/gu, ' ')}`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件数据目录解析与旧版数据迁移。
|
|
3
|
+
*
|
|
4
|
+
* 平台约定:插件私有数据统一放在 `$DSH_HOME/storages/<plugin-id>/`,
|
|
5
|
+
* `DSH_HOME` 由宿主注入,未设置时回落 `~/.dsh`。本插件历史版本把数据直接
|
|
6
|
+
* 写在 `~/.dsh/dsh-litsearch/`,这里负责一次性搬迁到统一存储区:
|
|
7
|
+
*
|
|
8
|
+
* - 只复制、不删除,旧目录原样保留作备份;
|
|
9
|
+
* - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
|
|
10
|
+
* - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
|
|
11
|
+
*
|
|
12
|
+
* PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
|
|
13
|
+
* 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
|
|
14
|
+
*/
|
|
15
|
+
export declare const PLUGIN_ID = "dsh-plugin-litsearch";
|
|
16
|
+
/** 历史版本使用的目录名(`$DSH_HOME` 下的一级目录)。 */
|
|
17
|
+
export declare const LEGACY_DIR = "dsh-litsearch";
|
|
18
|
+
/** 解析宿主数据根目录;支持绝对路径、`~/...` 与空值回落。 */
|
|
19
|
+
export declare function dshHome(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
20
|
+
/** 当前数据根目录:平台统一存储区下的插件域。 */
|
|
21
|
+
export declare function storageRoot(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
22
|
+
/** 旧数据根目录候选,按优先级排列,已排除新根目录。 */
|
|
23
|
+
export declare function legacyRoots(env?: NodeJS.ProcessEnv, home?: string): string[];
|
|
24
|
+
/** 目录里是否存在至少一个任务正文文件(索引与快照不算)。 */
|
|
25
|
+
export declare function hasTaskFiles(root: string): Promise<boolean>;
|
|
26
|
+
export interface StorageResolution {
|
|
27
|
+
/** 最终使用的数据根目录。 */
|
|
28
|
+
root: string;
|
|
29
|
+
/** 本次复制的文件数。 */
|
|
30
|
+
copied: number;
|
|
31
|
+
/** 从该旧目录迁移成功。 */
|
|
32
|
+
migratedFrom?: string;
|
|
33
|
+
/** 迁移失败,暂时回退到该旧目录读取。 */
|
|
34
|
+
fallbackFrom?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 决定最终数据根目录,并在必要时做一次性迁移:
|
|
38
|
+
*
|
|
39
|
+
* 1. 新目录已有任务 → 直接使用,绝不碰旧数据;
|
|
40
|
+
* 2. 新目录为空且旧目录有任务 → 复制缺失文件到新目录,旧目录保留;
|
|
41
|
+
* 3. 复制失败或复制后仍无任务 → 回滚本次副本并回退到旧目录读取。
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveStorageRoot(env?: NodeJS.ProcessEnv, home?: string): Promise<StorageResolution>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件数据目录解析与旧版数据迁移。
|
|
3
|
+
*
|
|
4
|
+
* 平台约定:插件私有数据统一放在 `$DSH_HOME/storages/<plugin-id>/`,
|
|
5
|
+
* `DSH_HOME` 由宿主注入,未设置时回落 `~/.dsh`。本插件历史版本把数据直接
|
|
6
|
+
* 写在 `~/.dsh/dsh-litsearch/`,这里负责一次性搬迁到统一存储区:
|
|
7
|
+
*
|
|
8
|
+
* - 只复制、不删除,旧目录原样保留作备份;
|
|
9
|
+
* - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
|
|
10
|
+
* - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
|
|
11
|
+
*
|
|
12
|
+
* PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
|
|
13
|
+
* 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
|
|
14
|
+
*/
|
|
15
|
+
import { copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
export const PLUGIN_ID = 'dsh-plugin-litsearch';
|
|
19
|
+
/** 历史版本使用的目录名(`$DSH_HOME` 下的一级目录)。 */
|
|
20
|
+
export const LEGACY_DIR = 'dsh-litsearch';
|
|
21
|
+
/** 允许搬迁的条目:任务正文与索引、导出文件、PDF 去重缓存、当前任务快照。 */
|
|
22
|
+
const MIGRATABLE_ENTRIES = ['tasks', 'out', 'pdfs', 'current.json'];
|
|
23
|
+
/** 解析宿主数据根目录;支持绝对路径、`~/...` 与空值回落。 */
|
|
24
|
+
export function dshHome(env = process.env, home = homedir()) {
|
|
25
|
+
const raw = env.DSH_HOME?.trim() ?? '';
|
|
26
|
+
if (raw === '')
|
|
27
|
+
return join(home, '.dsh');
|
|
28
|
+
if (raw === '~')
|
|
29
|
+
return home;
|
|
30
|
+
if (raw.startsWith('~/'))
|
|
31
|
+
return join(home, raw.slice(2));
|
|
32
|
+
// 绝对路径原样规范化,相对路径按进程工作目录解析(与宿主 path.resolve 一致)。
|
|
33
|
+
return resolve(raw);
|
|
34
|
+
}
|
|
35
|
+
/** 当前数据根目录:平台统一存储区下的插件域。 */
|
|
36
|
+
export function storageRoot(env = process.env, home = homedir()) {
|
|
37
|
+
return join(dshHome(env, home), 'storages', PLUGIN_ID);
|
|
38
|
+
}
|
|
39
|
+
/** 旧数据根目录候选,按优先级排列,已排除新根目录。 */
|
|
40
|
+
export function legacyRoots(env = process.env, home = homedir()) {
|
|
41
|
+
const current = storageRoot(env, home);
|
|
42
|
+
const candidates = [
|
|
43
|
+
join(dshHome(env, home), LEGACY_DIR), // 过渡形态:$DSH_HOME/dsh-litsearch
|
|
44
|
+
join(home, '.dsh', LEGACY_DIR), // 历史硬编码:~/.dsh/dsh-litsearch
|
|
45
|
+
];
|
|
46
|
+
return [...new Set(candidates)].filter((path) => path !== current);
|
|
47
|
+
}
|
|
48
|
+
/** 目录里是否存在至少一个任务正文文件(索引与快照不算)。 */
|
|
49
|
+
export async function hasTaskFiles(root) {
|
|
50
|
+
const names = await readdir(join(root, 'tasks')).catch(() => []);
|
|
51
|
+
return names.some((name) => name.endsWith('.json') && name !== 'index.json');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 决定最终数据根目录,并在必要时做一次性迁移:
|
|
55
|
+
*
|
|
56
|
+
* 1. 新目录已有任务 → 直接使用,绝不碰旧数据;
|
|
57
|
+
* 2. 新目录为空且旧目录有任务 → 复制缺失文件到新目录,旧目录保留;
|
|
58
|
+
* 3. 复制失败或复制后仍无任务 → 回滚本次副本并回退到旧目录读取。
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveStorageRoot(env = process.env, home = homedir()) {
|
|
61
|
+
const root = storageRoot(env, home);
|
|
62
|
+
if (await hasTaskFiles(root))
|
|
63
|
+
return { root, copied: 0 };
|
|
64
|
+
for (const legacy of legacyRoots(env, home)) {
|
|
65
|
+
if (!(await hasTaskFiles(legacy)))
|
|
66
|
+
continue;
|
|
67
|
+
const copied = [];
|
|
68
|
+
try {
|
|
69
|
+
await copyLegacyStorage(legacy, root, copied);
|
|
70
|
+
if (await hasTaskFiles(root)) {
|
|
71
|
+
console.info(`[dsh-plugin-litsearch] 任务数据已迁移:${legacy} → ${root}(${copied.length} 个文件,原目录保留)`);
|
|
72
|
+
return { root, migratedFrom: legacy, copied: copied.length };
|
|
73
|
+
}
|
|
74
|
+
await rollback(copied);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
await rollback(copied);
|
|
78
|
+
console.warn('[dsh-plugin-litsearch] 任务数据迁移失败:', error);
|
|
79
|
+
}
|
|
80
|
+
console.warn(`[dsh-plugin-litsearch] 暂时沿用旧任务目录:${legacy}`);
|
|
81
|
+
return { root: legacy, fallbackFrom: legacy, copied: 0 };
|
|
82
|
+
}
|
|
83
|
+
return { root, copied: 0 };
|
|
84
|
+
}
|
|
85
|
+
/** 复制旧目录里的已知条目;目标已存在则跳过,复制路径写入 `copied` 供回滚。 */
|
|
86
|
+
async function copyLegacyStorage(from, to, copied) {
|
|
87
|
+
for (const entry of MIGRATABLE_ENTRIES) {
|
|
88
|
+
const source = join(from, entry);
|
|
89
|
+
const target = join(to, entry);
|
|
90
|
+
const info = await stat(source).catch(() => null);
|
|
91
|
+
if (info === null)
|
|
92
|
+
continue;
|
|
93
|
+
if (info.isFile()) {
|
|
94
|
+
await copyFileIfAbsent(source, target, copied);
|
|
95
|
+
}
|
|
96
|
+
else if (info.isDirectory()) {
|
|
97
|
+
await copyDirIfAbsent(source, target, copied);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function copyDirIfAbsent(source, target, copied) {
|
|
102
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
// 跳过原子写残留的临时文件与系统杂项。
|
|
105
|
+
if (entry.name.startsWith('.') || entry.name.endsWith('.tmp'))
|
|
106
|
+
continue;
|
|
107
|
+
const from = join(source, entry.name);
|
|
108
|
+
const to = join(target, entry.name);
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
await copyDirIfAbsent(from, to, copied);
|
|
111
|
+
}
|
|
112
|
+
else if (entry.isFile()) {
|
|
113
|
+
await copyFileIfAbsent(from, to, copied);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function copyFileIfAbsent(source, target, copied) {
|
|
118
|
+
if ((await stat(target).catch(() => null)) !== null)
|
|
119
|
+
return;
|
|
120
|
+
await mkdir(dirname(target), { recursive: true });
|
|
121
|
+
await copyFile(source, target);
|
|
122
|
+
copied.push(target);
|
|
123
|
+
}
|
|
124
|
+
/** 迁移未完成时清掉本次副本,让下次启动可以重新迁移(源文件始终不动)。 */
|
|
125
|
+
async function rollback(files) {
|
|
126
|
+
for (const file of files)
|
|
127
|
+
await rm(file, { force: true }).catch(() => { });
|
|
128
|
+
}
|