@ggboy123/dsh-paper-reader 1.3.3 → 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -14
- package/dist/host.js +104 -17
- package/dist/library.js +127 -30
- package/lib/client.js +14 -2
- package/package.json +3 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GGboya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
> **👉 普通用户直接看「安装 → 方式一」**:装 DeepSeek 官方桌面端,添加插件即可,不需要任何开发环境。本仓库面向的是想自己装插件、或想改代码的用户。
|
|
13
13
|
|
|
14
|
+
> 💬 **QQ 交流群:1127244508** —— 安装/使用问题、功能建议、bug 反馈都欢迎来聊。
|
|
15
|
+
|
|
14
16
|
功能迁移自本地独立产品 pdfqa(Go 实现的论文伴读工具)。原则:agent 循环 / 模型层 / 会话持久化全部交给 dsh 框架,插件只做「转录 + 检索 + 阅读器 UI」的薄壳组合。
|
|
15
17
|
|
|
16
18
|
**安装后不改动官方界面** —— 侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式(左侧变为文献库树),再点即还原官方工作区列表:
|
|
@@ -37,14 +39,14 @@
|
|
|
37
39
|
|
|
38
40
|
## 安装
|
|
39
41
|
|
|
40
|
-
###
|
|
42
|
+
### 普通用户:桌面端装好即用(推荐)
|
|
43
|
+
|
|
44
|
+
**两步**:装 DeepSeek 官方桌面端 → 里面直接装插件。
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
1. 下载安装 [DeepSeek 官方桌面端](https://www.deepseek.com/download/)(即 DeepSeek Harness 桌面版),登录 DeepSeek 账号
|
|
47
|
+
2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@latest`,装完点进插件详情打开「启用」开关,**重启桌面端**
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@1.3.3` 安装
|
|
46
|
-
3. 装完默认**停用**:点进插件详情,打开「启用」开关
|
|
47
|
-
4. **重启桌面端**(热启用状态下打开论文的链路会静默失效,重启进开机组合才正常,实测)
|
|
49
|
+
完成。侧栏底部点「📚 论文伴读」进入文献库,上传 PDF 开始用。
|
|
48
50
|
|
|
49
51
|
完整流程演示(添加插件 → 启用 → 重启 → 打开论文直接提问):
|
|
50
52
|
|
|
@@ -53,23 +55,28 @@
|
|
|
53
55
|
> 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过:侧栏文献库、新建专题、上传 PDF、PDF 阅读器、原生对话伴读、历史/新建对话按钮全部可用。
|
|
54
56
|
> 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
|
|
55
57
|
|
|
56
|
-
>
|
|
58
|
+
> 想直接在客户端里逛插件市场(4000+ 插件、分类、评论、一键安装),可另装社区插件 [`dshmarket`](https://github.com/dsh-market/dsh-market),本插件已在其目录中。
|
|
59
|
+
|
|
60
|
+
**排坑(都是实测踩过的)**:
|
|
57
61
|
|
|
58
|
-
|
|
62
|
+
- **升级后插件开关会回到停用**:侧栏 → 插件 → 打开「启用」→ 重启。症状有迷惑性——文献树还在但点论文没反应(浏览器侧缓存活着、宿主侧已下线),其实是没启用
|
|
63
|
+
- **添加时务必带 `@latest`**:不带版本号对已装过的插件是 no-op(dsh 的插件管理器按首次记录的版本处理,不会追新)
|
|
64
|
+
- 旧版 PaperReader 桌面版(社区 DSH Desktop 预装打包)**已停止维护**,请迁移到本方式
|
|
65
|
+
|
|
66
|
+
### 开发者:命令行 dsh
|
|
59
67
|
|
|
60
68
|
```bash
|
|
61
|
-
dsh plugin --profile web add @ggboy123/dsh-paper-reader
|
|
69
|
+
dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest
|
|
62
70
|
```
|
|
63
71
|
|
|
64
72
|
> npm 包名走 `@ggboy123` 作用域:裸名 `dsh-paper-reader` 已被另一个插件占用(那是「输入文献、吐精读报告」的一次性分析工具,与本插件的「阅读器 + 会话伴读」定位不同)。
|
|
65
73
|
> 不要用 `github:GGboya/dsh-paper-reader` 安装 —— dist 不入库,git 安装会导致 profile 起不来(构建产物只随 npm 包分发)。
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
**已装过的用户升级必须带显式版本号**——不带版本的 `add` 对已存在的依赖是 no-op(pnpm 按首次安装时记录的版本范围解析,不会追新):
|
|
75
|
+
**升级**:用 `@latest`(或显式版本号)——不带任何版本/tag 的 `add` 对已装过的依赖是 no-op(实测):
|
|
70
76
|
|
|
71
77
|
```bash
|
|
72
|
-
dsh plugin --profile web add @ggboy123/dsh-paper-reader@
|
|
78
|
+
dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # 永远装最新
|
|
79
|
+
# dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.3.4 # 或钉住具体版本
|
|
73
80
|
# 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
|
|
74
81
|
```
|
|
75
82
|
|
|
@@ -79,7 +86,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.3.3
|
|
|
79
86
|
grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json
|
|
80
87
|
```
|
|
81
88
|
|
|
82
|
-
> pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N
|
|
89
|
+
> pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N 小时内的新包拒装,桌面端默认开):要么等过窗口期,要么在 `~/.dsh/profiles/web/.npmrc` 加一行 `minimum-release-age-exclude[]=@ggboy123/dsh-paper-reader`(只豁免本插件,不动全局策略)。桌面端的豁免清单在 `~/.dsh/profiles/desktop/pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude`。
|
|
83
90
|
|
|
84
91
|
### 参与开发(本地调试)
|
|
85
92
|
|
package/dist/host.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createReadStream, readFileSync } from 'node:fs';
|
|
|
4
4
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { basename, join } from 'node:path';
|
|
7
|
-
import { deletePaper, deleteTopic, listPapers, listTopics, paperRefFor, renamePaper, renameTopic, resolveDataDir, resolvePaper, } from "./library.js";
|
|
7
|
+
import { deletePaper, deleteTopic, listPapers, listTopics, paperRefFor, renamePaper, renameTopic, resolveDataDir, resolvePaper, resolveUploadTopic, validateEntryBoundary, validateEntryName, } from "./library.js";
|
|
8
8
|
import { readTranscript, transcribePaper } from "./transcribe.js";
|
|
9
9
|
import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
|
|
10
10
|
import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
|
|
@@ -16,12 +16,32 @@ function json(res, status, body) {
|
|
|
16
16
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length });
|
|
17
17
|
res.end(buf);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* 错误文案判别:带 `.code` 的 Node errno 系统错误 → 固定中文文案(不回显原始错误,
|
|
21
|
+
* 避免把 dataDir/目标绝对路径带进响应体);不带 `.code` 的是我们自己抛的中文业务 Error
|
|
22
|
+
* (专题不存在/已存在同名/名字不能包含… 等)→ 保留 `e.message`,用户仍能定位问题。
|
|
23
|
+
*/
|
|
24
|
+
function readableError(err) {
|
|
25
|
+
const code = err?.code;
|
|
26
|
+
if (typeof code === 'string' && code)
|
|
27
|
+
return '系统错误,请稍后重试';
|
|
28
|
+
return err instanceof Error ? err.message : String(err);
|
|
29
|
+
}
|
|
19
30
|
async function readBody(req) {
|
|
20
31
|
const chunks = [];
|
|
21
32
|
for await (const c of req)
|
|
22
33
|
chunks.push(c);
|
|
23
34
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
24
|
-
|
|
35
|
+
if (!raw)
|
|
36
|
+
return {};
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {}; // 畸形 JSON 与整包 null/非对象同待遇:路由据此给可读 400(不再 500)
|
|
43
|
+
}
|
|
44
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {};
|
|
25
45
|
}
|
|
26
46
|
/** header 元数据解码:客户端 encodeURIComponent 过(HTTP header 值不允许非 Latin-1,如中文文件名);非字符串/未编码原样返回。 */
|
|
27
47
|
function decodeHdr(v) {
|
|
@@ -122,7 +142,7 @@ export function registerRoutes(ctx, config) {
|
|
|
122
142
|
return workspace.workspaceId;
|
|
123
143
|
}
|
|
124
144
|
catch (err) {
|
|
125
|
-
console.warn('[dsh-paper-reader] workspace 注册失败(回退 cwd 模式):', err);
|
|
145
|
+
console.warn('[dsh-paper-reader] workspace 注册失败(回退 cwd 模式):', readableError(err));
|
|
126
146
|
return undefined;
|
|
127
147
|
}
|
|
128
148
|
};
|
|
@@ -182,14 +202,20 @@ export function registerRoutes(ctx, config) {
|
|
|
182
202
|
json(res, 200, { dataDir, topics: listTopics(dataDir), papers });
|
|
183
203
|
return;
|
|
184
204
|
}
|
|
185
|
-
//
|
|
205
|
+
// 新建专题(目录)。校验与 rename 的 to 侧一致(validateEntryName:边界 + 命名规范),
|
|
206
|
+
// 保证「能创建的必然能删除/改名」(R12:创建/删除对称)。
|
|
186
207
|
if (sub === '/api/library/topic' && req.method === 'POST') {
|
|
187
208
|
const body = (await readBody(req));
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
209
|
+
if (typeof body.name !== 'string' || !body.name.trim()) {
|
|
210
|
+
json(res, 400, { error: '专题名不能为空' });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const err = validateEntryName(body.name);
|
|
214
|
+
if (err) {
|
|
215
|
+
json(res, 400, { error: err });
|
|
191
216
|
return;
|
|
192
217
|
}
|
|
218
|
+
const name = body.name.trim();
|
|
193
219
|
await mkdir(join(dataDir, name), { recursive: true });
|
|
194
220
|
json(res, 200, { ok: true, topic: name });
|
|
195
221
|
return;
|
|
@@ -203,11 +229,24 @@ export function registerRoutes(ctx, config) {
|
|
|
203
229
|
json(res, 400, { error: '缺少 x-dpr-topic / x-dpr-name 头' });
|
|
204
230
|
return;
|
|
205
231
|
}
|
|
232
|
+
// 词法层:与其余 CRUD 同强度(../ / \ 隐藏名 控制字符 超长 空串)
|
|
233
|
+
const errName = validateEntryName(topic);
|
|
234
|
+
if (errName) {
|
|
235
|
+
json(res, 400, { error: errName });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
206
238
|
if (!filename.toLowerCase().endsWith('.pdf')) {
|
|
207
239
|
json(res, 400, { error: '只支持 PDF 文件' });
|
|
208
240
|
return;
|
|
209
241
|
}
|
|
210
242
|
const safeName = filename.replaceAll('/', '_').replaceAll('\\', '_');
|
|
243
|
+
// 文件名也做边界类校验(空/分隔符/../NUL),与删除侧对称:upload 能创建的必须能被删
|
|
244
|
+
const stem = safeName.slice(0, -4); // 去掉 .pdf
|
|
245
|
+
const errStem = validateEntryBoundary(stem);
|
|
246
|
+
if (errStem) {
|
|
247
|
+
json(res, 400, { error: errStem });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
211
250
|
const chunks = [];
|
|
212
251
|
for await (const c of req)
|
|
213
252
|
chunks.push(c);
|
|
@@ -216,10 +255,24 @@ export function registerRoutes(ctx, config) {
|
|
|
216
255
|
json(res, 400, { error: '文件内容为空' });
|
|
217
256
|
return;
|
|
218
257
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
258
|
+
// realpath 子路径层:拒绝「data/<topic> → 库外」符号链接逃逸写(与 CRUD 同一套 primitive)
|
|
259
|
+
let dir;
|
|
260
|
+
try {
|
|
261
|
+
dir = resolveUploadTopic(dataDir, topic.trim());
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
json(res, 400, { error: readableError(err) });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
await mkdir(dir, { recursive: true });
|
|
269
|
+
const target = join(dir, safeName);
|
|
270
|
+
await writeFile(target, buf);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
json(res, 400, { error: readableError(err) });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
223
276
|
json(res, 200, { ok: true, topic: topic.trim(), name: safeName.slice(0, -4), bytes: buf.length });
|
|
224
277
|
return;
|
|
225
278
|
}
|
|
@@ -236,15 +289,37 @@ export function registerRoutes(ctx, config) {
|
|
|
236
289
|
json(res2, 409, { error: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试' });
|
|
237
290
|
}
|
|
238
291
|
else {
|
|
239
|
-
json(res2, 400, { error:
|
|
292
|
+
json(res2, 400, { error: readableError(err) });
|
|
240
293
|
}
|
|
241
294
|
};
|
|
295
|
+
/** 非法/越界名字 → 400 可读文案(词法层;realpath 子路径层在 library primitive 内兜底)。
|
|
296
|
+
* 放在 busyPapersInTopic / resolvePaper 之前,越界输入不会碰任何文件系统。返回 true 表示已响应。 */
|
|
297
|
+
const badEntry = (res, raw) => {
|
|
298
|
+
const err = typeof raw === 'string' ? validateEntryName(raw) : '名字不能为空';
|
|
299
|
+
if (!err)
|
|
300
|
+
return false;
|
|
301
|
+
json(res, 400, { error: err });
|
|
302
|
+
return true;
|
|
303
|
+
};
|
|
304
|
+
/** 删除/改名-from 用:只做边界类校验(安全),不做命名规范类(长度/前导点)。
|
|
305
|
+
* 删除是对已存在文件的操作——upload 能创建的名字(前导点、超长、\x01 等)必须能删。 */
|
|
306
|
+
const badEntryBoundary = (res, raw) => {
|
|
307
|
+
const err = typeof raw === 'string' ? validateEntryBoundary(raw) : '名字不能为空';
|
|
308
|
+
if (!err)
|
|
309
|
+
return false;
|
|
310
|
+
json(res, 400, { error: err });
|
|
311
|
+
return true;
|
|
312
|
+
};
|
|
242
313
|
if (sub === '/api/library/rename-topic' && req.method === 'POST') {
|
|
243
314
|
const body = (await readBody(req));
|
|
244
|
-
if (!body.topic
|
|
315
|
+
if (typeof body.topic !== 'string' || !body.topic.trim()) {
|
|
245
316
|
json(res, 400, { error: '缺少 topic' });
|
|
246
317
|
return;
|
|
247
318
|
}
|
|
319
|
+
if (badEntry(res, body.topic))
|
|
320
|
+
return;
|
|
321
|
+
if (badEntry(res, body.to ?? ''))
|
|
322
|
+
return;
|
|
248
323
|
const busy = busyPapersInTopic(body.topic.trim());
|
|
249
324
|
if (busy.length) {
|
|
250
325
|
json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
|
|
@@ -263,10 +338,12 @@ export function registerRoutes(ctx, config) {
|
|
|
263
338
|
}
|
|
264
339
|
if (sub === '/api/library/delete-topic' && req.method === 'POST') {
|
|
265
340
|
const body = (await readBody(req));
|
|
266
|
-
if (!body.topic
|
|
341
|
+
if (typeof body.topic !== 'string' || !body.topic.trim()) {
|
|
267
342
|
json(res, 400, { error: '缺少 topic' });
|
|
268
343
|
return;
|
|
269
344
|
}
|
|
345
|
+
if (badEntry(res, body.topic))
|
|
346
|
+
return;
|
|
270
347
|
const busy = busyPapersInTopic(body.topic.trim());
|
|
271
348
|
if (busy.length) {
|
|
272
349
|
json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
|
|
@@ -284,10 +361,16 @@ export function registerRoutes(ctx, config) {
|
|
|
284
361
|
}
|
|
285
362
|
if (sub === '/api/library/rename-paper' && req.method === 'POST') {
|
|
286
363
|
const body = (await readBody(req));
|
|
287
|
-
if (!body.topic
|
|
364
|
+
if (typeof body.topic !== 'string' || !body.topic.trim() || typeof body.name !== 'string' || !body.name.trim()) {
|
|
288
365
|
json(res, 400, { error: '缺少 topic / name' });
|
|
289
366
|
return;
|
|
290
367
|
}
|
|
368
|
+
if (badEntry(res, body.topic))
|
|
369
|
+
return;
|
|
370
|
+
if (badEntryBoundary(res, body.name))
|
|
371
|
+
return;
|
|
372
|
+
if (badEntry(res, body.to ?? ''))
|
|
373
|
+
return;
|
|
291
374
|
let ref;
|
|
292
375
|
try {
|
|
293
376
|
ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
|
|
@@ -313,10 +396,14 @@ export function registerRoutes(ctx, config) {
|
|
|
313
396
|
}
|
|
314
397
|
if (sub === '/api/library/delete-paper' && req.method === 'POST') {
|
|
315
398
|
const body = (await readBody(req));
|
|
316
|
-
if (!body.topic
|
|
399
|
+
if (typeof body.topic !== 'string' || !body.topic.trim() || typeof body.name !== 'string' || !body.name.trim()) {
|
|
317
400
|
json(res, 400, { error: '缺少 topic / name' });
|
|
318
401
|
return;
|
|
319
402
|
}
|
|
403
|
+
if (badEntry(res, body.topic))
|
|
404
|
+
return;
|
|
405
|
+
if (badEntryBoundary(res, body.name))
|
|
406
|
+
return;
|
|
320
407
|
let ref;
|
|
321
408
|
try {
|
|
322
409
|
ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
|
|
@@ -650,7 +737,7 @@ export function registerRoutes(ctx, config) {
|
|
|
650
737
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
651
738
|
dispatch(req, res, url).catch((err) => {
|
|
652
739
|
if (!res.headersSent)
|
|
653
|
-
json(res, 500, { error:
|
|
740
|
+
json(res, 500, { error: readableError(err) });
|
|
654
741
|
else
|
|
655
742
|
res.end();
|
|
656
743
|
});
|
package/dist/library.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// <dataDir>/<专题>/<文献>.pages.json 页码偏移表(转录时生成,检索映射页码用)
|
|
7
7
|
// pdf2zh 的产物(-en / -zh / -dual)不算用户文献。
|
|
8
8
|
// (问答不落盘存档——会话持久化交给 dsh 自己;pdfqa 时代的 -qa/ 目录不再读写。)
|
|
9
|
-
import { readdirSync, existsSync, mkdirSync, statSync, renameSync, rmSync } from 'node:fs';
|
|
10
|
-
import { join, resolve, extname, basename } from 'node:path';
|
|
9
|
+
import { readdirSync, existsSync, mkdirSync, statSync, renameSync, rmSync, realpathSync, lstatSync } from 'node:fs';
|
|
10
|
+
import { join, resolve, extname, basename, sep } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
const PDF2ZH_SUFFIXES = ['-en', '-zh', '-dual'];
|
|
13
13
|
/** 判定是否为用户文献:排除 pdf2zh 的中间/产出文件。 */
|
|
@@ -111,10 +111,103 @@ export function validateEntryName(raw) {
|
|
|
111
111
|
return '名字过长(最多 120 个字符)';
|
|
112
112
|
if (/[\\/]|\.\./.test(name))
|
|
113
113
|
return '名字不能包含 / \\ 或 ..';
|
|
114
|
+
if (/[\u0000-\u001f\u007f]/.test(name))
|
|
115
|
+
return '名字不能包含控制字符';
|
|
114
116
|
if (name.startsWith('.'))
|
|
115
117
|
return '名字不能以 . 开头';
|
|
116
118
|
return null;
|
|
117
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* 边界类名字校验(删除/改名-from 用):只挡安全边界——空、分隔符/..、NUL。
|
|
122
|
+
* 与 validateEntryName 的分工:命名规范类(长度上限、前导点、非 NUL 控制字符)只在创建/改名时
|
|
123
|
+
* 强制(那是用户在起名字);删除是对已存在文件的操作——upload 能创建的名字(前导点、超长、
|
|
124
|
+
* \x01 等控制字符)必须能删,否则会成为无法清理的孤儿。NUL 必须挡:它会让 Node fs 抛
|
|
125
|
+
* ERR_INVALID_ARG_VALUE 并把绝对路径带进错误文案(错误回显卫生)。
|
|
126
|
+
*/
|
|
127
|
+
export function validateEntryBoundary(raw) {
|
|
128
|
+
const name = raw.trim();
|
|
129
|
+
if (!name)
|
|
130
|
+
return '名字不能为空';
|
|
131
|
+
if (/[\\/]|\.\./.test(name))
|
|
132
|
+
return '名字不能包含 / \\ 或 ..';
|
|
133
|
+
if (name.includes('\u0000'))
|
|
134
|
+
return '名字不能包含控制字符';
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// ================= 路径穿越防护(词法 + realpath 子路径双层) =================
|
|
138
|
+
// 这些 CRUD 原语以前只对 `to` 做过校验,`topic`/`name`/`from` 直接 `join(dataDir, x)`:
|
|
139
|
+
// `../sentinel` 这类输入会让 rmSync/renameSync 作用于 dataDir 之外的目录(可递归强制删除)。
|
|
140
|
+
// 这里补两层结构性防护:① 词法层(validateEntryName 拒绝 / \ .. 与隐藏名);② realpath
|
|
141
|
+
// 层(目标目录的 real 路径必须落在 dataDir 的 real 路径之内,挡住 `..` 之外的符号链接逃逸)。
|
|
142
|
+
/** dataDir 的 real 绝对路径(resolve + realpath,去符号链接与 `..`)。目录缺失时给无路径文案。 */
|
|
143
|
+
function realDataDir(dataDir) {
|
|
144
|
+
const abs = resolve(dataDir);
|
|
145
|
+
if (!existsSync(abs))
|
|
146
|
+
throw new Error('文献库数据目录不存在');
|
|
147
|
+
return realpathSync(abs);
|
|
148
|
+
}
|
|
149
|
+
/** target 必须落在 root 之内(严格子路径,不含 root 本身),否则视为越界。 */
|
|
150
|
+
function assertInside(root, target) {
|
|
151
|
+
const r = root.endsWith(sep) ? root : root + sep;
|
|
152
|
+
if (target !== root && !target.startsWith(r))
|
|
153
|
+
throw new Error('路径越界,已拒绝');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 安全解析专题目录:词法校验 + realpath 严格子路径包含。
|
|
157
|
+
* 返回可直接 rename/delete 的目录路径(join 后的路径,非 realpath——顶层符号链接对
|
|
158
|
+
* rmSync/renameSync 只操作链接本身,不跟随,语义保持不变)。任何调用方(不只路由)
|
|
159
|
+
* 传入越界 topic 都会被拒:`..`、绝对路径、`a/../b`、指向库外的符号链接等。
|
|
160
|
+
*/
|
|
161
|
+
function resolveSafeTopic(dataDir, topic) {
|
|
162
|
+
const err = validateEntryName(topic);
|
|
163
|
+
if (err)
|
|
164
|
+
throw new Error(err);
|
|
165
|
+
const root = realDataDir(dataDir);
|
|
166
|
+
const dir = join(root, topic.trim());
|
|
167
|
+
if (!existsSync(dir))
|
|
168
|
+
throw new Error(`专题不存在: ${topic.trim()}`);
|
|
169
|
+
let real;
|
|
170
|
+
try {
|
|
171
|
+
real = realpathSync(dir);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error(`专题不存在: ${topic.trim()}`);
|
|
175
|
+
}
|
|
176
|
+
assertInside(root, real);
|
|
177
|
+
return dir;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 安全解析「待创建」的专题目录(upload 用):词法校验 + realpath 严格子路径包含。
|
|
181
|
+
* 与 resolveSafeTopic 不同:专题可以尚不存在(upload 会 mkdir 创建),但已存在的条目
|
|
182
|
+
* (目录/文件/符号链接)仍必须 realpath 落在 dataDir 之内——挡住「data/evil → 库外」这类
|
|
183
|
+
* 符号链接逃逸写。返回可直接 mkdir/writeFile 的目录路径(join 后的路径,非 realpath)。
|
|
184
|
+
*/
|
|
185
|
+
export function resolveUploadTopic(dataDir, topic) {
|
|
186
|
+
const err = validateEntryName(topic);
|
|
187
|
+
if (err)
|
|
188
|
+
throw new Error(err);
|
|
189
|
+
const root = realDataDir(dataDir);
|
|
190
|
+
const dir = join(root, topic.trim());
|
|
191
|
+
let exists = false;
|
|
192
|
+
try {
|
|
193
|
+
lstatSync(dir); // lstat 不跟随符号链接:悬空链接也算「已存在」,交给 realpath 兜底
|
|
194
|
+
exists = true;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
exists = false;
|
|
198
|
+
}
|
|
199
|
+
if (!exists)
|
|
200
|
+
return dir; // 尚不存在:mkdir(recursive) 会创建真实目录(词法层已保证无分隔符/../隐藏名)
|
|
201
|
+
let real;
|
|
202
|
+
try {
|
|
203
|
+
real = realpathSync(dir);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new Error(`专题不存在: ${topic.trim()}`);
|
|
207
|
+
}
|
|
208
|
+
assertInside(root, real);
|
|
209
|
+
return dir;
|
|
210
|
+
}
|
|
118
211
|
/** 构造文献引用(不做存在性校验;给只按路径判定的调用方用,如翻译 busy 检查)。 */
|
|
119
212
|
export function paperRefFor(dataDir, topic, name) {
|
|
120
213
|
const pdfPath = join(dataDir, topic, name + '.pdf');
|
|
@@ -129,53 +222,59 @@ export function derivedFilesFor(pdfPath) {
|
|
|
129
222
|
const stem = pdfPath.slice(0, -'.pdf'.length);
|
|
130
223
|
return PAPER_SIDECARS.map((s) => stem + s);
|
|
131
224
|
}
|
|
132
|
-
/** 改名专题(目录 rename)。from 不存在 / to 已存在
|
|
225
|
+
/** 改名专题(目录 rename)。from/to 非法(含越界)→ throw;from 不存在 / to 已存在 → throw 中文 Error。
|
|
133
226
|
* 已知局限:大小写不敏感文件系统(macOS/Windows)上纯大小写改名会被「目标已存在」
|
|
134
227
|
* 拒绝——不做临时名两跳绕过,保持简单。 */
|
|
135
228
|
export function renameTopic(dataDir, from, to) {
|
|
136
229
|
const err = validateEntryName(to);
|
|
137
230
|
if (err)
|
|
138
231
|
throw new Error(err);
|
|
139
|
-
if (to.trim() === from)
|
|
232
|
+
if (to.trim() === from.trim())
|
|
140
233
|
return;
|
|
141
|
-
const src =
|
|
142
|
-
|
|
143
|
-
throw new Error(`专题不存在: ${from}`);
|
|
144
|
-
const dst = join(dataDir, to.trim());
|
|
234
|
+
const src = resolveSafeTopic(dataDir, from); // 词法 + realpath 子路径校验 from
|
|
235
|
+
const dst = join(realDataDir(dataDir), to.trim());
|
|
145
236
|
if (existsSync(dst))
|
|
146
237
|
throw new Error(`已存在同名专题: ${to.trim()}`);
|
|
147
238
|
renameSync(src, dst);
|
|
148
239
|
}
|
|
149
240
|
/** 改名文献:连带派生文件(转录/页码索引/译文/学习档案)逐个跟随,不存在的跳过。
|
|
150
|
-
* 返回实际改名的文件名清单(含 .pdf 本体)。 */
|
|
241
|
+
* 返回实际改名的文件名清单(含 .pdf 本体)。topic/from/to 非法(含越界)→ throw。 */
|
|
151
242
|
export function renamePaper(dataDir, topic, from, to) {
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
throw new Error(
|
|
155
|
-
|
|
156
|
-
if (
|
|
243
|
+
const errTo = validateEntryName(to);
|
|
244
|
+
if (errTo)
|
|
245
|
+
throw new Error(errTo);
|
|
246
|
+
const errFrom = validateEntryBoundary(from);
|
|
247
|
+
if (errFrom)
|
|
248
|
+
throw new Error(errFrom);
|
|
249
|
+
const toTrim = to.trim();
|
|
250
|
+
const fromTrim = from.trim();
|
|
251
|
+
if (toTrim === fromTrim)
|
|
157
252
|
return [];
|
|
158
|
-
const dir =
|
|
159
|
-
if (!existsSync(join(dir,
|
|
160
|
-
throw new Error(`文献不存在: ${topic}/${
|
|
161
|
-
if (existsSync(join(dir,
|
|
162
|
-
throw new Error(`已存在同名文献: ${
|
|
253
|
+
const dir = resolveSafeTopic(dataDir, topic); // 词法 + realpath 子路径校验 topic
|
|
254
|
+
if (!existsSync(join(dir, fromTrim + '.pdf')))
|
|
255
|
+
throw new Error(`文献不存在: ${topic.trim()}/${fromTrim}`);
|
|
256
|
+
if (existsSync(join(dir, toTrim + '.pdf')))
|
|
257
|
+
throw new Error(`已存在同名文献: ${toTrim}`);
|
|
163
258
|
const renamed = [];
|
|
164
259
|
for (const suf of PAPER_SIDECARS) {
|
|
165
|
-
const src = join(dir,
|
|
260
|
+
const src = join(dir, fromTrim + suf);
|
|
166
261
|
if (!existsSync(src))
|
|
167
262
|
continue;
|
|
168
|
-
renameSync(src, join(dir,
|
|
169
|
-
renamed.push(
|
|
263
|
+
renameSync(src, join(dir, toTrim + suf));
|
|
264
|
+
renamed.push(fromTrim + suf);
|
|
170
265
|
}
|
|
171
266
|
return renamed;
|
|
172
267
|
}
|
|
173
|
-
/** 删除文献及其全部派生文件。返回实际删除的文件名清单。 */
|
|
268
|
+
/** 删除文献及其全部派生文件。返回实际删除的文件名清单。topic/name 非法(含越界)→ throw。 */
|
|
174
269
|
export function deletePaper(dataDir, topic, name) {
|
|
175
|
-
const
|
|
176
|
-
|
|
270
|
+
const errName = validateEntryBoundary(name);
|
|
271
|
+
if (errName)
|
|
272
|
+
throw new Error(errName);
|
|
273
|
+
const dir = resolveSafeTopic(dataDir, topic); // 词法 + realpath 子路径校验 topic
|
|
274
|
+
const nameTrim = name.trim();
|
|
275
|
+
const pdfPath = join(dir, nameTrim + '.pdf');
|
|
177
276
|
if (!existsSync(pdfPath))
|
|
178
|
-
throw new Error(`文献不存在: ${topic}/${
|
|
277
|
+
throw new Error(`文献不存在: ${topic.trim()}/${nameTrim}`);
|
|
179
278
|
const deleted = [];
|
|
180
279
|
for (const f of derivedFilesFor(pdfPath)) {
|
|
181
280
|
if (!existsSync(f))
|
|
@@ -185,10 +284,8 @@ export function deletePaper(dataDir, topic, name) {
|
|
|
185
284
|
}
|
|
186
285
|
return deleted;
|
|
187
286
|
}
|
|
188
|
-
/** 删除专题(递归,含其下全部文献与派生文件)。 */
|
|
287
|
+
/** 删除专题(递归,含其下全部文献与派生文件)。topic 非法(含越界)→ throw(不触碰库外)。 */
|
|
189
288
|
export function deleteTopic(dataDir, topic) {
|
|
190
|
-
const dir =
|
|
191
|
-
if (!existsSync(dir))
|
|
192
|
-
throw new Error(`专题不存在: ${topic}`);
|
|
289
|
+
const dir = resolveSafeTopic(dataDir, topic);
|
|
193
290
|
rmSync(dir, { recursive: true, force: true });
|
|
194
291
|
}
|
package/lib/client.js
CHANGED
|
@@ -631,12 +631,17 @@ window.__ModuleLoader__.load({
|
|
|
631
631
|
|
|
632
632
|
// 激活会话:客户端会话目录(catalog)感知到新会话有时延,轮询到「已列出」再
|
|
633
633
|
// openSession,并以 current === sessionId 为成功标准(否则原生对话区不切换)。
|
|
634
|
+
// ⚠️ 仅凭 catalog 的 current 判断不够(用户实测「点插件页后再点论文没反应」):
|
|
635
|
+
// 插件页等非对话视图占着主视图时,目录里 cur 仍是目标会话,但视图并不在对话——
|
|
636
|
+
// 提前返回会让主视图永远停在插件页、PDF 锚点不回来。补一个可见事实作成功标准:
|
|
637
|
+
// PDF 页签锚点回来(对话视图已恢复);没回来就持续 openSession 往回推。
|
|
634
638
|
const activateSession = useCallback((sessionId) => {
|
|
635
639
|
let tries = 20
|
|
636
640
|
const tick = () => {
|
|
637
641
|
try {
|
|
638
642
|
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
639
|
-
|
|
643
|
+
const anchorBack = !!document.querySelector('[data-dsh-surface="rightbar-tab-anchor"]')
|
|
644
|
+
if (currentSessionOf(snap) === sessionId && anchorBack) return // 会话已切 + 视图已回
|
|
640
645
|
const listed = !!(snap && (snap.byId?.[sessionId] || snap.ids?.includes(sessionId)))
|
|
641
646
|
if (listed) uiWorkspace.openSession(sessionId)
|
|
642
647
|
if (--tries > 0) setTimeout(tick, 300)
|
|
@@ -660,7 +665,14 @@ window.__ModuleLoader__.load({
|
|
|
660
665
|
if (listed) {
|
|
661
666
|
// 对目录里早已存在的会话这步能真正预开(store 已 adopt);
|
|
662
667
|
// 对刚创建的会话恒 no-op —— 靠切换护栏兜底(见 beginSwitchGuard)。
|
|
663
|
-
|
|
668
|
+
// ⚠️ openTabIn 失败不能堵住 done()(用户实测「点插件页后再点论文没反应」):
|
|
669
|
+
// 插件页等非对话视图占着主视图时,目标会话在右栏没有 seat,openTabIn 每次都抛——
|
|
670
|
+
// 若当可重试错误轮询,done() 永远不执行 → activateSession(openSession 切回对话
|
|
671
|
+
// 视图)不被调用 → 主视图停在插件页、PDF 锚点永不回来。预开只是优化,失败就
|
|
672
|
+
// 直接放行,切换护栏 + openPdfTab 轮询兜底。
|
|
673
|
+
try {
|
|
674
|
+
sidebarRight.openTabIn(sessionId, 'dpr-reader', { revealIfOpened: true })
|
|
675
|
+
} catch { /* 无 seat(插件页/会话未收编)——切换后由 openPdfTab 补开 */ }
|
|
664
676
|
done?.()
|
|
665
677
|
return
|
|
666
678
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggboy123/dsh-paper-reader",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "tsc -p tsconfig.json",
|
|
47
47
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"pretest": "npm run build",
|
|
49
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
48
50
|
"smoke:engine": "node scripts/engine-smoke.mjs",
|
|
49
51
|
"prepublishOnly": "tsc -p tsconfig.json"
|
|
50
52
|
},
|