Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5b9072ffd | ||
|
|
d406a0a508 | ||
|
|
6841cfe38f | ||
|
|
8190259ad5 | ||
|
|
3f8ae81a14 | ||
|
|
cd28a8ad38 | ||
|
|
a09a4413ee | ||
|
|
30523b4a77 | ||
|
|
7ff101044a | ||
|
|
09fae64f1f | ||
|
|
4cb7ac7100 | ||
|
|
7ca023023e | ||
|
|
cb7b020dc8 | ||
|
|
f72c26642f | ||
|
|
0fd7c59e08 | ||
|
|
522b0f74a5 | ||
|
|
381c07733a | ||
|
|
2c5c7b1828 | ||
|
|
353f9193c2 | ||
|
|
efad306312 | ||
|
|
3ccd044527 |
@@ -0,0 +1,185 @@
|
|||||||
|
name: 构建与发布
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
# 仓库的 .npmrc 指向 npmmirror,GitHub runner 在境外,走官方源更稳
|
||||||
|
npm_config_registry: https://registry.npmjs.org/
|
||||||
|
ELECTRON_MIRROR: https://github.com/electron/electron/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: 单测与集成测试
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: 校验发布标签与版本号一致
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('标签应为 '+expected+',实际 '+process.env.GITHUB_REF_NAME)}"
|
||||||
|
|
||||||
|
- name: 安装依赖
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# 下载完整日志需要仓库管理员权限。失败详情转成注解,这样没有管理员权限
|
||||||
|
# 的人也能在提交页直接看到是哪条断言挂了,不必去翻日志。
|
||||||
|
- name: 单元测试
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npm test > /tmp/unit.log 2>&1 || {
|
||||||
|
# 注解有长度上限,只挑失败条目与错误细节,别把通过的用例也塞进去
|
||||||
|
grep -E '^(✖|not ok)' /tmp/unit.log | head -20
|
||||||
|
echo "::error title=单元测试失败::$(grep -E '^(✖|not ok)|AssertionError|actual:|expected:|operator:|^\s+at ' /tmp/unit.log \
|
||||||
|
| head -40 | cut -c1-300 | sed 's/%/%25/g; s/\r//g' | awk '{printf "%s%%0A", $0}')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
tail -6 /tmp/unit.log
|
||||||
|
|
||||||
|
# Electron 集成测试要开真实窗口,无头环境靠 xvfb 提供 X server
|
||||||
|
- name: Electron 集成测试
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
annotate() {
|
||||||
|
echo "::error title=$1::$(tail -80 "$2" | sed 's/%/%25/g; s/\r//g' | awk '{printf "%s%%0A", $0}')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
sudo apt-get update > /tmp/apt.log 2>&1 || annotate "apt 更新失败" /tmp/apt.log
|
||||||
|
# runner 自带字体极少。界面文案是中文,缺 CJK 字体会渲染成豆腐块,
|
||||||
|
# 文本版式测量也会跟着偏,断言量到的就不是真实排版。
|
||||||
|
sudo apt-get install -y xvfb libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 \
|
||||||
|
libcups2t64 libgbm1 libasound2t64 libgtk-3-0t64 \
|
||||||
|
fonts-liberation fonts-noto-core fonts-noto-cjk >> /tmp/apt.log 2>&1 \
|
||||||
|
|| annotate "运行库安装失败" /tmp/apt.log
|
||||||
|
fc-cache -f > /dev/null 2>&1 || true
|
||||||
|
# runner 上的 chrome-sandbox 拿不到 root:root 4755,SUID 沙箱起不来。
|
||||||
|
# 只在 CI 关沙箱,不要把这个开关带进构建产物。
|
||||||
|
for suite in startup download cover annotation reader-features library-notes ai-scope; do
|
||||||
|
echo "::group::$suite"
|
||||||
|
xvfb-run -a npx electron --no-sandbox "src/_test/electron/$suite.integration.js" \
|
||||||
|
> "/tmp/$suite.log" 2>&1 || { echo "::endgroup::"; annotate "集成测试失败 $suite" "/tmp/$suite.log"; }
|
||||||
|
tail -3 "/tmp/$suite.log"
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
|
||||||
|
package:
|
||||||
|
name: 打包 ${{ matrix.platform }} ${{ matrix.arch }}
|
||||||
|
needs: validate
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: windows
|
||||||
|
arch: x64
|
||||||
|
runner: windows-2025
|
||||||
|
- platform: macos
|
||||||
|
arch: arm64
|
||||||
|
runner: macos-15
|
||||||
|
- platform: linux
|
||||||
|
arch: x64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- platform: linux
|
||||||
|
arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: 缓存 Electron 运行时
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache
|
||||||
|
key: electron-${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
|
- name: 安装依赖
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 构建并校验发布件
|
||||||
|
run: npm run release -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: 上传发布件
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: peoplelib-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
path: dist/release/${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
if-no-files-found: error
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: 发布 GitHub Release
|
||||||
|
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||||
|
needs: package
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: 核对标签指向当前提交
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
expected="v$(node -p "require('./package.json').version")"
|
||||||
|
test "$GITHUB_REF_NAME" = "$expected"
|
||||||
|
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: 下载各平台发布件
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: peoplelib-*
|
||||||
|
path: dist/release-downloads
|
||||||
|
|
||||||
|
- name: 回验校验和并汇总
|
||||||
|
run: npm run release -- --verify dist/release-downloads
|
||||||
|
|
||||||
|
# 先建草稿再转正式,避免上传中途失败留下一个资产不全的 Release
|
||||||
|
- name: 创建 Release 并上传
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="$GITHUB_REF_NAME"
|
||||||
|
if gh release view "$tag" >/dev/null 2>&1; then
|
||||||
|
gh release edit "$tag" --draft --verify-tag
|
||||||
|
else
|
||||||
|
gh release create "$tag" --draft --verify-tag --generate-notes --title "PeopleLib $tag"
|
||||||
|
fi
|
||||||
|
gh release upload "$tag" dist/release-upload/* --clobber
|
||||||
|
gh release edit "$tag" --draft=false
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
# 必须锚定到仓库根:不加斜杠会连 icons/dist/ 一起忽略,
|
||||||
|
# 而构建脚本和单测都依赖 icons/dist/ 里的图标产物
|
||||||
|
/dist/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# 图标工具生成的对照图,仅供肉眼检查,不参与构建
|
||||||
|
icons/dist/preview*.png
|
||||||
|
|
||||||
# 调试探测产生的临时快照
|
# 调试探测产生的临时快照
|
||||||
probe*.json
|
probe*.json
|
||||||
probe-*.js
|
probe-*.js
|
||||||
*.tmp.html
|
*.tmp.html
|
||||||
scihub.html
|
scihub.html
|
||||||
|
*-diagnostic.png
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmmirror.com
|
||||||
proxy=
|
|
||||||
https-proxy=
|
|
||||||
noproxy=*
|
noproxy=*
|
||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
|
||||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# AGENTS
|
||||||
|
|
||||||
|
面向在本仓库工作的 AI 代理。约定之外的部分按代码里的既有写法照做。
|
||||||
|
|
||||||
|
## 项目性质
|
||||||
|
|
||||||
|
Electron 桌面应用:多源文献检索 + 本地书库 + 内置阅读器(PDF/EPUB/MOBI/AZW)+ 批注笔记 + AI 助手。
|
||||||
|
Windows x64 免安装版,用户数据在程序同级 `data/`。
|
||||||
|
|
||||||
|
## 语言与风格
|
||||||
|
|
||||||
|
- 界面文案、提交信息、注释一律中文;标识符英文。
|
||||||
|
- 默认不写注释。只在原因不显然时写:隐藏约束、易踩的坑、绕过某个具体 bug 的原因。不要复述代码在做什么。
|
||||||
|
- 不用 emoji。散文里避免破折号。
|
||||||
|
- 回复简洁,不主动扩展用户没要求的事。
|
||||||
|
|
||||||
|
## 硬性约束
|
||||||
|
|
||||||
|
### 安全边界不可绕过
|
||||||
|
|
||||||
|
- 渲染层永远不能传任意路径读盘。所有文件访问必须过 `main.js` 的 `resolveReadable()`,它只放行书库中真实登记的条目。
|
||||||
|
- 分段读取会话(`src/reader/range-sessions.js`)与 sender 绑定,句柄不透明,webContents 销毁即回收。新增 IPC 时必须校验 sender。
|
||||||
|
- 所有 `ipcMain.handle` 走 `wrap()`,同步抛出也要变成 `{ ok:false }`,否则渲染层的 `await` 无 catch,界面会永远卡在加载中。
|
||||||
|
- 不要把密钥、token 写进日志或错误信息。AI Key 经 `safeStorage` 加密后落盘。
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
- 前端第三方库全部 vendored 在 `src/ui/vendor/`,版本在 `package.json` 中锁死(不用 `^`)。不要引入新的运行时依赖,除非用户明确要求。
|
||||||
|
- `src/ui/vendor/pdf.worker.range.mjs` 是**手工 patch 过的** PDF.js worker,不要用上游文件覆盖。见下文。
|
||||||
|
|
||||||
|
## PDF 分段读取(易踩坑)
|
||||||
|
|
||||||
|
超大 PDF 不整文件读入内存,走 `reader:rangeOpen/rangeRead/rangeClose`。`reader:bytes` 直接拒绝 PDF。
|
||||||
|
|
||||||
|
worker 选择策略在 `pdf-adapter.mjs`:
|
||||||
|
|
||||||
|
- ≤256 MB:用**官方** worker(`pdf.worker.min.mjs`),即使是 range 模式。
|
||||||
|
- >256 MB:用稀疏 worker(`pdf.worker.range.mjs`)。
|
||||||
|
|
||||||
|
稀疏 worker 的基础缓冲区是空的(`new Uint8Array(0)`),因此**任何对 `stream.bytes.buffer` 直接建视图的代码都会抛 RangeError**。已知踩过的坑:`preEvaluateFont` 里的 `ToUnicode` 哈希,错误表现是字体静默降级成不可见的 `ErrorFont`(文本项数量正常,但画布上没有墨迹)。必须用 `stream.getByteRange(start, end)`。
|
||||||
|
|
||||||
|
改动这个 worker 后,用真实 PDF 对比三条路径的渲染结果(文本项数 + 非白像素数):官方 worker 全量数据 / 官方 worker range / 稀疏 worker range。三者应一致。
|
||||||
|
|
||||||
|
## PDF 渲染画质
|
||||||
|
|
||||||
|
- 底栏「画质」档位(1/2/3)经 `renderOpts` 下发为 `opts.renderQuality`,持久化在 `reader.pdfRenderQuality`。
|
||||||
|
- 生效倍率是 `max(devicePixelRatio, renderQuality)`,**HiDPI 屏上不叠乘**。所以断言时不能写「backing 翻倍」,只能断言 `backing / CSS 宽度 == 期望倍率`;开发机 dpr 常见 1.75,写死 2 会假失败。
|
||||||
|
- 画布尺寸必须过 `clampCanvasSize()`:Chromium 单边上限 16384px、面积上限 268435456px。超限时浏览器**静默给出不可用画布**,整页空白且不报错。大幅面图纸(2000pt 以上)在 scale 5 下必然踩中。
|
||||||
|
- 钳制后 backing 与 CSS 盒子的比例不再等于名义倍率,`pdfPage.render` 的 `transform` 必须用 `fit.scaleX` / `fit.scaleY`(两个方向分别算,`Math.floor` 的损失不同),否则画面错位或只画出一角。
|
||||||
|
- 倍率变化必须走 `epoch++` 整篇重建,只改新渲染的页会让同屏出现清晰度不一致。
|
||||||
|
- 集成测试里量画质前要先把视口滚回目标页:离屏页会被回收成空白占位,量到的是占位画布。画布尺寸在重建时立即变大,墨迹要等重绘完才落上去,断言墨迹必须轮询而不是只等比例。
|
||||||
|
|
||||||
|
## TXT 与 Markdown
|
||||||
|
|
||||||
|
- `text-adapter.mjs` 把 txt/md 转成内存 EPUB 再交给 `epub-adapter` 渲染,定位器 `kind` 为 `txt` / `md`,结构与 epub 同源(`chapter` + `offset`)。新增重排格式时记得同步 `REFLOW_KINDS`。
|
||||||
|
- 格式白名单有六处,改一处不够:`main.js` 的 `READABLE_EXT`、`library/store.js` 与 `library/local-import.js` 的 `BOOK_EXT`、`main.js` 里选文件对话框的 `extensions`,以及**渲染层的两份** `READABLE_RE`(`ui/views/library.js`、`ui/reader/shell.mjs`)。单测有正则锁死并逐项比对渲染层与 `READABLE_EXT` 是否一致。
|
||||||
|
- 渲染层那两份漏掉格式**不会报错**:IPC 照样能打开,但卡片上的「阅读」按钮和封面点击入口静默消失,阅读器的「从书库打开」列表里也少掉这些书。用户看到的现象是"内置阅读器打不开 txt"。集成测试里直接调 `reader.open` 是查不出来的,必须从书库界面点「阅读」。
|
||||||
|
- 书库卡片的「阅读」取的是**第一个可阅读文件**。同一条书目既有 txt 又有 pdf 时,加进白名单后打开的文件会从 pdf 变成 txt,断言里不要写死 PDF 画布。
|
||||||
|
- Markdown 走 markdown-it(`html: false`)+ DOMPurify 双保险。裸 HTML 会被转义成文本,所以**净化断言必须按 DOM 查**(`querySelectorAll('script')`、`on*` 属性、`a[href]` 协议),用 `innerHTML` 匹配 `onerror` 会把转义后的字面量误判成漏网。
|
||||||
|
- 编码探测支持 UTF-8 / UTF-16LE / UTF-16BE(含 BOM)与 GB18030。带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本乱码且用户无从修正。
|
||||||
|
|
||||||
|
## AI 助手
|
||||||
|
|
||||||
|
- 上下文范围:`selection | page | document | page-image | region-image`。
|
||||||
|
- 只有 `selection` 需要选中文本;`page` 和 `document` 不依赖选区。
|
||||||
|
- `document` 无论多短都强制弹确认框,并提示可能超出模型上下文限制。
|
||||||
|
- 正文**完整发送,不做本地截断**。模型窗口够不够由接口自己判断,超限时把报错转成中文提示(`isContextOverflow` / `overflowHint`)。曾经按 `MAX_CHARS = 12000` 挖空中间,实测 40 页文档只发出 8 页,而界面仍显示全文字数,属于静默数据丢失,已移除。`clipContext` 保留给有明确预算上限的场合显式调用。
|
||||||
|
- 界面展示的字数必须等于真正外发的字数。任何"先按上限裁剪再发送"的改动都要同步改 `aiCost` 与确认框文案,否则用户会把答非所问归因于模型。
|
||||||
|
- 图像只用 JPEG 单一编码路径,1600px / 目标 400 KB。**不要加格式回退**(PNG 等),维护成本高于收益;视觉模型按像素图块计费,格式不影响费用。
|
||||||
|
- 三种协议:Anthropic `/messages`、OpenAI Responses `/responses`、兼容 Chat `/chat/completions`。图像负载格式各不相同,改动时三条都要验。
|
||||||
|
|
||||||
|
## AI 多轮会话
|
||||||
|
|
||||||
|
- 会话存在 `reader-ai-sessions/`,一个会话一个 JSON,外加可重建的 `index.json`。不进 `store.js`,避免整本阅读进度被聊天记录带着反复重写。
|
||||||
|
- `ai:run` 里顺序是硬约束:**先 `historyFor()` 再 `appendUser()`**。顺序反了,当前这轮提问会出现在自己的历史里,模型收到两遍同样的问题。
|
||||||
|
- 同一会话禁止并发生成(`ai:run` 里按 `sessionId` 查 `aiRuns`)。两轮同时写一个文件,后完成的那轮会把前一轮的消息覆盖掉。
|
||||||
|
- 消息正文存的是**用户看见的那句提问**(`aiTurnTitle`),整篇正文只在 `contextRef` 里留 scope / 字数 / 哈希。把正文当消息存会让重开后的气泡变成十几万字原文,还会被 `LIMITS.question` 截成一段无意义的残句。
|
||||||
|
- 失败和取消都要落盘,并且要把**已经流出来的残片**一起存(`streamed`)。只写空串的话,界面上明明显示着半截回答,一重开就消失。
|
||||||
|
- 历史只发文本:`stream()` 只取 `role` 与 `text`,图像一律不重发。历史里内联 base64 会让每轮费用随轮数线性上涨。
|
||||||
|
- 落盘失败不能把已经拿到的回答变成请求失败,`settleAiAssistant` 吞掉异常。
|
||||||
|
- 书籍删除、孤立对账都要带上会话(`aiSessions.forgetMany` / `orphanReport`),删完调 `collectAiImages()`。内容寻址的图片没有引用者就永远不会被回收。
|
||||||
|
- 集成测试里断言历史时**不能按固定下标取消息**:本轮提问固定在末尾,中间是历史。原来写死 `messages[1]` 的断言在多轮上线后会取到上一轮,表现为"图像尺寸无效"这种完全无关的报错。
|
||||||
|
|
||||||
|
## 笔记独立窗口
|
||||||
|
|
||||||
|
- `src/reader/note-window.js` 是**单窗口多标签**:一个笔记窗口,一条笔记一个标签,已开则切到该标签。同一条笔记两处编辑时 `reader:updateNote` 是整条覆盖、无版本校验,后保存者会把画布内容整块吃掉,所以「一条笔记只能有一个编辑器」是数据安全约束,不是体验优化。`openTab` 必须先 `tabOf()` 查重。
|
||||||
|
- 阅读器内的笔记模态**保留**,因此「模态 + 独立窗口」仍可能撞车。靠列表按钮避开:已开窗时「编辑」变成「在窗口中编辑」并转为聚焦窗口,不再开模态。
|
||||||
|
- 笔记消失时必须**关掉对应标签**(不是销毁整个窗口):`reader:removeNote` 调 `closeFor`,`library:removeMany` 与 `reader:purgeOrphans` 调 `closeForEntries`。留着标签,它下一次保存会把已删的笔记整条写回去。最后一个标签关掉后窗口才自行退场。
|
||||||
|
- 三处窗口广播(`notifyNotesChanged`、`applyWindowIcons`、`notifyUiThemeChanged`)都要带 `noteWindow.all()`,漏一处笔记窗口就收不到笔记变更或主题切换。单测有正则锁死。
|
||||||
|
- `notes:getOne` 要校验 `noteWindow.ownsNote(event.sender, noteId)`:笔记窗口只能读**自己已开标签**的那几条,否则这个通道就是遍历全部笔记的后门。开窗目标也必须过 `findNote()` 用 `listNotes()` 重新对账。
|
||||||
|
- `notes:tabsChanged`(`setTabs`)只能**收窄**标签集,即只允许 `openNotes.delete`,绝不能 `set`。允许渲染层往里加 ID 等于让它自己扩权:谎报持有某条笔记后 `notes:getOne` 立刻放行,授权集合就形同虚设。新增标签只能走 `open()`,那条路径过 `findNote()` 对账。
|
||||||
|
- 关窗拦截三件套必须配套:`close` 里 `preventDefault()` + `closePending` + 看门狗,渲染层处理完调 `notes:shutdownReady`,**用户取消时必须调 `notes:cancelClose`**。少了取消回报,`closePending` 一直为真会让之后每次点关闭都被当成「正在处理」静默忽略,而看门狗仍会在十秒后把带未保存内容的窗口直接销毁。
|
||||||
|
- 笔记没有自动保存。切换标签只留在内存,LRU 回收前要把未保存内容序列化进 `pendingContent`,否则回收即丢改动。
|
||||||
|
- 脏判定必须比对**序列化后的内容**(`contentKey` 与挂载后取的 `baselineKey`),不能用 `pointerdown`/`keydown` 之类的交互事件:只点选不改字也会被判脏,每个标签关闭时都弹一次无谓的确认。基线要在编辑器 `ready()` 之后取(画布有 version 1→2 归一化,拿磁盘原值当基线会让刚打开就显示已修改),保存成功后基线要跟着前移。
|
||||||
|
- 笔记窗口**不**纳入 `isReaderSender`,不获得 AI 会话等阅读器权限。
|
||||||
|
- PDF 底版草稿按 sender 隔离(`resolveDrafts` / `readDraft`),草稿不能跨窗口交接,笔记窗口必须自己 stage。
|
||||||
|
- 编辑区吃满整窗要逐层 `min-height: 0`,缺一层 flex 子项就被内容顶高、画布溢出窗口。
|
||||||
|
- 笔记本下拉框要 `max-width` + `min-width: 0`。只给 `max-width` 不够:`select` 的 min-content 以最长选项为准,长书名/长笔记本名照样把整行顶宽(实测取消限宽后从 260px 涨到 553px)。
|
||||||
|
- 标题栏只放品牌名「笔记」,不要副标题。`note.html` 用的是 `style.css`,那里的 `.titlebar-left` **不是** flex(只有 `reader.css` 才是),把 `brand-sub` 放成 `.brand` 的同级会掉到下一行,把左侧块顶成 46px 而标题栏只有 44px。要加副标题只能像 `index.html` 那样塞进 `.brand` 内部。
|
||||||
|
- `note.html` 不加载 `reader.css`,所以标签条样式必须在 `note-window.css` 里**自带一份**,也不能用只在 `reader.css` 里定义的变量(如 `--hover-bg-soft`),否则静默失效。
|
||||||
|
- 集成测试四个坑:窗口刚建好时 `getURL()` 还是空串,找窗口必须轮询;断言窗口数量只能数**笔记窗口**,用总窗口数当基线会被阅读器窗口的开关搅乱;笔记页同时有多张卡片,找按钮必须限定在 `.note-card[data-note-id=...]` 内,全局找「编辑」会命中别的卡片;多标签后表单是每标签一份,查询要限定在当前激活的 `.note-tab-view` 内,或直接按 `[data-note-id]` 定位,否则量到的是别的标签。
|
||||||
|
- 删除笔记要走真实 IPC(`reader:removeNote`),它内部已经调了 `closeFor`。测试里再手工补一次 `closeFor` 等于在验证自己造的假路径,还会因为重复处理而看到「标签没关掉」的假失败。
|
||||||
|
|
||||||
|
## 书库卡片封面
|
||||||
|
|
||||||
|
- 封面比例来源不一(内置生成 400x500,书源常见 0.65~0.75,还有方图和横图)。卡片盒子固定 `aspect-ratio: 3/4`,用 `background-size: cover` 会按各自比例裁掉不同的边,观感就是「预览大小不一致」,但**量盒子是量不出问题的**(每张都一样宽高),必须看截图或比对可见的封面内容。
|
||||||
|
- 现在用 `contain` 完整显示封面,留白由 `::before` 里同一张图放大模糊后垫底。注意 **`::before` 会盖在父元素自己的背景之上**,所以清晰的那层必须单独画在 `::after` 里,只调 `z-index` 是压不住父元素背景的;照这个顺序:`::before`(模糊,z-index 0)→ `::after`(清晰,z-index 1)→ `.card-cover > *`(角标文字,z-index 2)。两个伪元素都要 `pointer-events: none`,否则挡掉封面的阅读点击。
|
||||||
|
- 多选复选框不要加 `padding` + 背景色块。13px 的原生复选框套一圈衬底后看起来像加粗了边框,浅色封面上的可见性用 `filter: drop-shadow(...)` 解决。
|
||||||
|
|
||||||
|
## 笔记表单的选择器边界
|
||||||
|
|
||||||
|
`.note-edit-form` 里嵌着画布工具栏(`.canvas-note-root`)与富文本工具栏(`.ql-toolbar`),两者都有 `<select>`。因此 `.note-edit-form select { ... }` 这类**后代选择器会一路灌进工具栏**,必须显式 `:not(.canvas-note-root select):not(.ql-toolbar select)`。
|
||||||
|
|
||||||
|
踩过的坑:表单控件的 `margin-top: 5px` 落到工具栏的粗细/纸张下拉上,分组高度变成 28 与 33 两种,工具栏从一行变两行(42px → 78px)。表现像是「工具栏没横排、需要 flex-wrap 调整」,改 `.canvas-note-tool-group` 的 wrap/shrink 只能把 78px 压到 46px,剩下的 4px 差和分组错位依然在,因为根因不在布局属性而在这条越界的样式。
|
||||||
|
|
||||||
|
诊断方法:量到子项全是 28px 而父分组是 33px 时,不要继续猜 flex 属性,直接遍历 `document.styleSheets` 找出 `el.matches(rule.selectorText)` 的全部规则,越界的那条会立刻现形。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # Node 单测
|
||||||
|
npx electron src/_test/electron/<name>.integration.js # Electron 集成
|
||||||
|
```
|
||||||
|
|
||||||
|
集成套件:`ai-scope`、`reader-features`、`library-notes`、`annotation`、`download`、`cover`、`startup`。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 完成任务前跑单测 + 相关集成套件。
|
||||||
|
- 单测里有对源码的正则断言(锁死关键约定)。改了被断言的代码要同步更新断言,不要为了让测试过而弱化断言。
|
||||||
|
- 集成测试断言"真正离开进程的内容"(真实本地 HTTP 服务收到的 body、真实渲染出的像素),不要 stub 渲染层。
|
||||||
|
- 不要为了测试往生产代码里加 `window.__test` 之类的全局钩子,通过真实 UI 断言。
|
||||||
|
- `library-notes` 偶发失败,重跑确认再判断。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Windows,输出 dist/PeopleLib-windows-x64/
|
||||||
|
npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑
|
||||||
|
npm run build:linux -- --arch x64 # Linux,输出 tar.gz,任意平台可构建
|
||||||
|
npm run release -- --platform linux --arch x64 # 发布件 + 校验和
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
- 输出目录固定,不带版本号。改名会导致 `data/` 被遗留在旧目录。
|
||||||
|
- 构建保留 `data/`,但会清空其余内容。**构建前必须退出该目录下运行中的 `PeopleLib.exe`**,否则清理到一半失败,目录处于不完整状态。
|
||||||
|
- 涉及 `data/` 的操作前后做逐文件哈希比对,确认书库、笔记、批注未被改动。
|
||||||
|
|
||||||
|
macOS(`build-mac.js`,易踩坑):
|
||||||
|
|
||||||
|
- **不能交叉构建**。DMG 需要 `hdiutil`,且 Apple Silicon 内核直接拒绝执行未签名二进制;改名与改 plist 会让 Electron 原始签名失效,必须用 macOS 的 `codesign` 重签(ad-hoc `--sign -` 即可)。
|
||||||
|
- 解压官方 zip 必须用 `ditto`。`Electron Framework.framework` 内有符号链接,用 Node 或 `unzip` 解压会展开成副本,签名随即失效。
|
||||||
|
- 重打包后 `Info.plist` 里的 `ElectronAsarIntegrity` 必须删除,否则启动即报完整性错误。
|
||||||
|
- helper 的 plist **没有** `CFBundleExecutable`,靠 bundle 名推断可执行文件名。重命名 helper 后必须显式补上该字段,否则渲染进程起不来,界面一片空白。
|
||||||
|
- 签名严格由内向外:嵌套可执行文件 → helper → framework → 外层 `.app`。带版本的 framework 签 `Versions/A`;`Squirrel.framework` 的 `Resources/ShipIt` 是独立可执行文件,要单独签。
|
||||||
|
- 数据目录走 `~/Library/Application Support/PeopleLib`,**不要**沿用 Windows 的便携布局:`.app` 在 DMG 里只读,且升级覆盖会删掉用户书库。
|
||||||
|
- `.icns` 由 `npm run icons:icns` 生成,纯 Node 实现(icns 自 10.7 起内嵌 PNG),不依赖 macOS 的 `iconutil`。窗口图标在非 Windows 平台用 PNG,`.ico` 只有 Windows 认。
|
||||||
|
|
||||||
|
Linux(`build-linux.js`):
|
||||||
|
|
||||||
|
- 直接从官方 zip 的条目转写进 tar,**不落地中间目录**。可执行位存在 zip 的 external attributes 里,先解到 NTFS 再打包会全部丢掉,产物解压后主程序和 `chrome-sandbox` 都不可执行。因此这个脚本在 Windows 上也能构建。
|
||||||
|
- tar 头是手写的。路径超过 100 字节要走 PAX 扩展头:ustar 的 `prefix` 只能在斜杠处切分,`undici` 与 vendor 里的深层路径切不出合法组合。
|
||||||
|
|
||||||
|
发布件(`build-release.js`):
|
||||||
|
|
||||||
|
- 发布件的唯一出口,不要手工压缩构建目录上传。Windows 便携版的 `data/` 就在程序同级,手工压缩会把用户书库连同笔记打进公开发布件;脚本按前缀排除并在打包后回读压缩包确认。
|
||||||
|
- 打完包一定回读产物再签校验和:Linux 要确认可执行位还在,Windows 要确认没有 `data/` 与 `_test`。只算哈希不看内容,等于把「构建脚本改坏了」这类问题一路放到用户手上。
|
||||||
|
|
||||||
|
## 持续集成
|
||||||
|
|
||||||
|
- `.github/workflows/build.yml`:`validate`(单测 + 全部集成套件,`xvfb-run` 起 X server)→ `package`(四目标矩阵)→ `release`(仅 `v*` 标签)。
|
||||||
|
- 仓库 `.npmrc` 指向 npmmirror,GitHub runner 在境外拉不动,工作流用 `npm_config_registry` 与 `ELECTRON_MIRROR` 覆盖回官方源。新增构建步骤时别把这两个环境变量漏掉。
|
||||||
|
- 新增集成套件后要同步加进工作流的套件列表,单测里有断言按 `src/_test/electron/` 的实际文件逐个核对,漏加会直接失败。
|
||||||
|
- 标签名必须等于 `v` + `package.json` 的 `version`,且标签要指向被构建的那个提交。
|
||||||
|
- 下载完整日志要仓库管理员权限,所以失败详情都转成 `::error` 注解。改这几步时别把注解去掉,否则没有管理员权限的人只能看到「exit code 1」。
|
||||||
|
- runner 上 `chrome-sandbox` 拿不到 root:root 4755,集成测试必须带 `--no-sandbox`。这个开关只属于 CI,不要带进构建产物。
|
||||||
|
- runner 自带字体极少,工作流装了 Liberation 与 Noto CJK。界面文案是中文,缺 CJK 字体会渲染成豆腐块,版式测量也跟着偏。
|
||||||
|
|
||||||
|
Linux 上首次跑测试暴露过三类只在该平台成立的问题,新写代码时留意:
|
||||||
|
|
||||||
|
- `mtimeMs` 在 ext4/APFS 带亚毫秒小数,`Date.now()` 只到整毫秒。直接相减,刚落盘的文件年龄是负数,「超过宽限期就回收」的逻辑永远不触发。比较前先 `Math.floor`。
|
||||||
|
- 集成夹具要走网络时,代理只能读 `HTTPS_PROXY`,不能写死本机端口,否则 CI 上直接 ECONNREFUSED。
|
||||||
|
- 版式类断言的容差不要写死字符数,按实际渲染出的每行字数算。字体集不同,同一行的字数就不同,写死的数字换个平台就假失败。
|
||||||
|
|
||||||
|
## 仓库
|
||||||
|
|
||||||
|
两个远端,用途不同:
|
||||||
|
|
||||||
|
- `origin`(私有):完整源码,日常开发推这里。
|
||||||
|
- `github`(公开):源码与 CI。GitHub Actions 必须 checkout 到源码才能构建,所以公开仓不再只放 README。历史与本地无共同祖先,推送前先核对两边的差异范围。
|
||||||
|
|
||||||
|
推公开仓前必须确认:没有本地配置、账号凭据、`data/` 内容或诊断产物混进去。公开仓一旦推出去,删提交也留在别人的克隆里。
|
||||||
|
|
||||||
|
其他:
|
||||||
|
|
||||||
|
- 根目录 `dist/` 已 gitignore,规则写作 `/dist/`,**必须保留前导斜杠**:不加会连 `icons/dist/` 一起忽略,新克隆的仓库缺图标,构建直接失败。诊断产物(`*-diagnostic.png`、`probe*.json`)也已忽略,不要提交。
|
||||||
|
- 未跟踪文件视为用户资产,不要删除或覆盖。清理前先看 `git status --porcelain`。
|
||||||
|
- 提交前 `git diff --cached` 检查是否混入密钥。
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# 开发与构建
|
||||||
|
|
||||||
|
面向开发者的源码运行、打包、配置与架构说明。只想使用应用的用户请看 [README](README.md)。
|
||||||
|
|
||||||
|
## 源码运行与打包
|
||||||
|
|
||||||
|
源码开发需要 Node.js 22.19+:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 Windows 免安装版到 `dist/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run portable
|
||||||
|
```
|
||||||
|
|
||||||
|
输出目录固定为 `dist/PeopleLib-windows-x64/`,不随版本号变化,重复构建会保留其中的 `data/` 目录。构建前需退出该目录下正在运行的 `PeopleLib.exe`,否则会因文件占用而中止。
|
||||||
|
|
||||||
|
发布件不要手工压缩目录上传,用下面的发布打包入口生成,它会排除 `data/` 并附带校验和。
|
||||||
|
|
||||||
|
### Linux(x64 / arm64)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:linux -- --arch x64
|
||||||
|
npm run build:linux -- --arch arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
产出 `dist/PeopleLib-linux-<arch>.tar.gz`,解压后运行其中的 `PeopleLib.sh`。
|
||||||
|
|
||||||
|
脚本直接把官方 Electron zip 里的条目转写进 tar,不落地中间目录,因此在 Windows 上也能构建出可用的 Linux 包。可执行位存在 zip 的 external attributes 里,先解压到 NTFS 再打包会把这些位全部丢掉,产物解压后 `PeopleLib` 与 `chrome-sandbox` 都不可执行。
|
||||||
|
|
||||||
|
多数发行版开启了非特权用户命名空间,无需额外配置。内核禁用该特性时(`kernel.unprivileged_userns_clone=0`),需给沙箱补 setuid:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown root:root PeopleLib-linux-x64/chrome-sandbox
|
||||||
|
sudo chmod 4755 PeopleLib-linux-x64/chrome-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS(Apple Silicon)
|
||||||
|
|
||||||
|
**只能在 macOS 上执行**,且需要 Xcode 命令行工具(`xcode-select --install`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
产出 `dist/PeopleLib-macos-arm64/PeopleLib.app` 与 `dist/PeopleLib-macos-arm64.dmg`。
|
||||||
|
|
||||||
|
不能在 Windows 或 Linux 上交叉构建,原因有两条,都无法绕开:
|
||||||
|
|
||||||
|
- DMG 由 `hdiutil` 生成,该工具只存在于 macOS。
|
||||||
|
- Apple Silicon 内核会拒绝执行未签名的二进制。打包过程要重命名可执行文件、修改 `Info.plist`,Electron 的原始签名必然失效,必须用 macOS 的 `codesign` 重新签名。
|
||||||
|
|
||||||
|
脚本使用 ad-hoc 签名(`codesign --sign -`),可以在本机及自行放行的机器上运行,但未经 Apple 公证。首次打开需右键点按图标选择「打开」,或执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -dr com.apple.quarantine /Applications/PeopleLib.app
|
||||||
|
```
|
||||||
|
|
||||||
|
图标 `icons/dist/book-ai-*.icns` 已随仓库提供。源 PNG 变更后用 `npm run icons:icns` 重新生成,该脚本在任意平台都能运行,不依赖 macOS 的 `iconutil`。
|
||||||
|
|
||||||
|
## 发布打包
|
||||||
|
|
||||||
|
`build-release.js` 是发布件的唯一出口,负责调用平台构建脚本、校验产物内容、生成校验和:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release -- --platform windows --arch x64
|
||||||
|
npm run release -- --platform macos --arch arm64
|
||||||
|
npm run release -- --platform linux --arch x64
|
||||||
|
npm run release -- --platform linux --arch arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
加 `--skip-build` 可复用已有的构建产物。输出落在 `dist/release/<platform>-<arch>/`,含发布件、`SHA256SUMS.txt` 与 `release-manifest.json`。
|
||||||
|
|
||||||
|
发布前的校验是硬要求,不要跳过直接压缩目录上传:
|
||||||
|
|
||||||
|
- Windows 便携版把用户书库放在程序同级 `data/`,本机构建目录里通常有内容,手工压缩会把整个书库连同笔记打进公开发布件。脚本按前缀排除 `data/`,并在打包后回读压缩包确认。
|
||||||
|
- Linux 产物必须回读 tar 确认 `PeopleLib`、`PeopleLib.sh`、`chrome-sandbox` 带可执行位,丢了就是解压后点不开。
|
||||||
|
- 三个平台都会检查有没有混入 `_test`。
|
||||||
|
|
||||||
|
汇总多平台产物时用回验模式,它逐个比对哈希、体积与版本,再汇总到 `dist/release-upload/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release -- --verify dist/release-downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
## 持续集成
|
||||||
|
|
||||||
|
`.github/workflows/build.yml` 在推送 `main`、提交 PR、打 `v*` 标签和手动触发时运行,分三个阶段:
|
||||||
|
|
||||||
|
1. `validate`:`npm ci` 后跑单测与全部 Electron 集成套件。集成测试要开真实窗口,无头 runner 上用 `xvfb-run` 提供 X server。
|
||||||
|
2. `package`:四个目标并行打包(`windows-2025`、`macos-15`、`ubuntu-24.04`、`ubuntu-24.04-arm`),各自调用 `npm run release`,产物作为 artifact 保留 30 天。
|
||||||
|
3. `release`:仅在推送 `v*` 标签时执行,回验各平台校验和后创建 GitHub Release 并上传。
|
||||||
|
|
||||||
|
两个容易踩的点:
|
||||||
|
|
||||||
|
- 仓库 `.npmrc` 指向 npmmirror,GitHub runner 在境外拉不动,工作流用 `npm_config_registry` 与 `ELECTRON_MIRROR` 覆盖回官方源。
|
||||||
|
- 标签名必须与 `package.json` 的 `version` 一致(`v2.0.0` 对应 `2.0.0`),且标签要指向被构建的那个提交,两处校验不过直接中止发布。
|
||||||
|
|
||||||
|
Release 先建草稿、上传完再转正式,上传中途失败不会在页面上留下一个资产不全的版本。应用根据最新 Release 标签判断是否需要更新。
|
||||||
|
|
||||||
|
## 固定构建变体
|
||||||
|
|
||||||
|
PeopleLib 采用固定构建变体,不使用远程开关在应用发布后改变功能范围:
|
||||||
|
|
||||||
|
| 构建 | 定位 | 功能范围 |
|
||||||
|
|---|---|---|
|
||||||
|
| 桌面完整版 | Windows、macOS | 多源检索、下载与任务中心、数据源账号、代理、本地书库、阅读和笔记 |
|
||||||
|
| 移动阅读版 | iPadOS、Android | 本地导入、书库、阅读、笔记、书签和批注 |
|
||||||
|
|
||||||
|
移动阅读版固定关闭以下能力:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
remoteSearch: false,
|
||||||
|
remoteDownload: false,
|
||||||
|
sourceAccounts: false,
|
||||||
|
proxy: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这些能力必须在构建时确定。移动端不展示相关入口、不注册对应桥接 API、不发起数据源请求,也不能通过服务端配置重新开启。桌面端继续保留完整功能。
|
||||||
|
|
||||||
|
移动端不能直接复用 Electron 包,需要使用独立的移动端外壳。下载、文件系统、安全存储和阅读文件访问均应通过 iPadOS/Android 原生桥接实现。首个移动版本只提供本地阅读能力;从系统文件选择器、分享面板或用户自行管理的云盘导入文件,不提供应用内在线检索和下载。
|
||||||
|
|
||||||
|
移动阅读版必须由专用脚本生成,不能依赖开发者手工删除页面或模块。计划提供以下固定入口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:android
|
||||||
|
npm run build:ios
|
||||||
|
```
|
||||||
|
|
||||||
|
两个命令应调用同一套移动构建脚本,并把平台与固定变体显式传入,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node build-mobile.js --platform android --variant reader-only
|
||||||
|
node build-mobile.js --platform ios --variant reader-only
|
||||||
|
```
|
||||||
|
|
||||||
|
`build-mobile.js` 必须完成:
|
||||||
|
|
||||||
|
1. 校验变体只能是 `reader-only`,拒绝从环境变量或远程配置开启受限能力。
|
||||||
|
2. 生成移动端能力清单,并在编译前固定关闭检索、下载、数据源账号和代理。
|
||||||
|
3. 使用移动端入口组装资源,不注册桌面端 IPC,不复制在线数据源模块。
|
||||||
|
4. 调用 Android 或 iOS 原生工程构建工具,并把产物输出到固定的 `dist/PeopleLib-android/` 或 `dist/PeopleLib-ios/`。
|
||||||
|
5. 对最终产物运行自动化检查,确认不存在检索、下载、数据源账号和代理入口。
|
||||||
|
|
||||||
|
Android 构建可以在 Windows、macOS 或 Linux 上执行;iOS/iPadOS 构建依赖 Xcode、签名与 Apple SDK,只能在 macOS 上执行。
|
||||||
|
|
||||||
|
当前仓库尚未包含 `build-mobile.js` 与移动端原生工程,所以上述命令是必须补齐的目标构建入口,目前不能生成移动安装包。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
### 代理
|
||||||
|
|
||||||
|
在应用内「设置」填写代理地址,例如 `http://127.0.0.1:7897`。留空表示直连。配置会持久化,重启后仍生效,并应用于所有数据源请求与封面加载。
|
||||||
|
|
||||||
|
网络受限环境下,多数数据源需要代理才能访问。
|
||||||
|
|
||||||
|
### Z-Library 账号
|
||||||
|
|
||||||
|
「设置」中填入邮箱与密码即可登录。登录后可获取下载直链(免费账号有每日下载额度限制)。
|
||||||
|
|
||||||
|
> 凭据以 base64 混淆后保存在本地 `zlib-auth.json`,**这只是防止肉眼直读,不是加密**。请勿在不受信任的机器上使用。
|
||||||
|
|
||||||
|
## 数据位置
|
||||||
|
|
||||||
|
| 模式 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| 开发运行(Windows) | `%APPDATA%/PeopleLib` |
|
||||||
|
| 开发运行(macOS) | `~/Library/Application Support/PeopleLib` |
|
||||||
|
| 打包运行(Windows 便携版) | 可执行文件同级的 `data/` 目录 |
|
||||||
|
| 打包运行(macOS) | `~/Library/Application Support/PeopleLib` |
|
||||||
|
|
||||||
|
macOS 不采用便携布局:`.app` 内部在 DMG 挂载时只读,且覆盖升级会连同用户书库一并删除。
|
||||||
|
|
||||||
|
该目录包含:
|
||||||
|
|
||||||
|
- `library.json` 书库索引
|
||||||
|
- `settings.json` 应用设置(代理等)
|
||||||
|
- `zlib-auth.json` Z-Library 凭据与会话
|
||||||
|
- `covers/` 封面缓存
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
main.js 主进程:窗口、IPC、代理与证书处理、文件下载
|
||||||
|
preload.js 渲染进程 API 桥接
|
||||||
|
src/
|
||||||
|
settings.js 设置持久化
|
||||||
|
library/store.js 书库存储
|
||||||
|
sources/
|
||||||
|
index.js 数据源注册表
|
||||||
|
http.js 统一 HTTP 层:超时、Cookie、代理
|
||||||
|
mirror.js 镜像故障转移
|
||||||
|
zlib-auth.js Z-Library 凭据存储
|
||||||
|
<source>.js 各数据源实现
|
||||||
|
ui/ 渲染进程界面
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据源接口
|
||||||
|
|
||||||
|
每个数据源模块导出以下结构:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
id: 'example',
|
||||||
|
name: '示例源',
|
||||||
|
supportsSearch: true,
|
||||||
|
|
||||||
|
async list(page) // 浏览:{ items, maxPage, page }
|
||||||
|
async search(keyword, page) // 搜索:{ items, maxPage, page }
|
||||||
|
async detail(postId) // 详情:{ title, authors, cover, tags, brief, links }
|
||||||
|
async download(postId) // 下载:{ files, links }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
新增数据源只需实现该接口,并在 `src/sources/index.js` 中注册。
|
||||||
|
|
||||||
|
### 网络层说明
|
||||||
|
|
||||||
|
- 主进程使用 Electron 的 `net.fetch`(Chromium 网络栈),代理通过 `session.setProxy` 生效
|
||||||
|
- 纯 Node 环境回退到 undici `ProxyAgent`(Electron 下不启用,该组合存在兼容问题)
|
||||||
|
- 所有请求默认 15 秒超时,可按调用点覆盖
|
||||||
|
- 失效镜像进入 5 分钟冷却,之后自动重试,避免站点恢复后被永久跳过
|
||||||
|
- **应用启动时全局忽略 TLS 证书错误**。这是为了兼容大量使用自签名或过期证书的镜像站点,代价是失去对中间人攻击的防护,请仅在受信任的网络环境中使用。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
单元测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
数据源联通性检查,对主要数据源依次执行搜索、详情、下载链路,输出每一步耗时与结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx electron test-search.js --proxy http://127.0.0.1:7897
|
||||||
|
```
|
||||||
@@ -2,15 +2,47 @@
|
|||||||
|
|
||||||
开放获取文献与图书的桌面客户端(Electron)。在一个界面里检索多个公开文献源,查看详情,下载文件并归入本地书库。
|
开放获取文献与图书的桌面客户端(Electron)。在一个界面里检索多个公开文献源,查看详情,下载文件并归入本地书库。
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
|
书库:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
多源检索,可选单个数据源或聚合全部:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
内置阅读器与 PDF 批注:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
AI 助手,可把选中文本、当前页、全文或框选区域作为上下文提问:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **多源检索**:12 个数据源统一的搜索、详情、下载流程
|
- **多源检索**:16 个数据源统一的搜索、详情、下载流程
|
||||||
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
|
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
|
||||||
|
- **任务中心**:全局查看下载进度,离开详情页后继续下载,支持暂停、断点续传和删除未完成任务
|
||||||
|
- **内置阅读器**:PDF、EPUB、无 DRM 的 MOBI/KF7/KF8 与 TXT/Markdown 阅读,支持进度、书签、选文和笔记
|
||||||
- **全局代理**:一处配置,对所有数据源与封面请求生效
|
- **全局代理**:一处配置,对所有数据源与封面请求生效
|
||||||
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
|
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
|
||||||
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
|
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
|
||||||
- **版本更新**:手动或启动时检查 GitHub Releases,发现新版本后前往下载
|
- **版本更新**:手动或启动时检查 GitHub Releases,发现新版本后前往下载
|
||||||
|
|
||||||
|
## 支持格式
|
||||||
|
|
||||||
|
| 格式 | 书库导入与管理 | 内置阅读 | 说明 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| PDF | ✓ | ✓ | 支持页面批注、书签、选文和笔记 |
|
||||||
|
| EPUB | ✓ | ✓ | 支持目录、重排、书签、选文和笔记 |
|
||||||
|
| MOBI / AZW / AZW3 | ✓ | ✓ | 使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容 |
|
||||||
|
| TXT / MD | ✓ | ✓ | 自动识别编码,Markdown 渲染标题、列表、代码块与表格 |
|
||||||
|
| DJVU / FB2 / CBZ / CBR | ✓ | — | 可入库、整理并调用系统关联应用打开 |
|
||||||
|
|
||||||
|
DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,可改用系统关联应用打开。
|
||||||
|
|
||||||
## 数据源
|
## 数据源
|
||||||
|
|
||||||
| 源 | ID | 说明 |
|
| 源 | ID | 说明 |
|
||||||
@@ -18,6 +50,10 @@
|
|||||||
| arXiv 论文 | `arxiv` | 预印本,支持全文检索 |
|
| arXiv 论文 | `arxiv` | 预印本,支持全文检索 |
|
||||||
| Gutenberg 公版书 | `gutenberg` | 公共领域图书 |
|
| Gutenberg 公版书 | `gutenberg` | 公共领域图书 |
|
||||||
| Open Library 图书 | `openlibrary` | 图书元数据与借阅入口 |
|
| Open Library 图书 | `openlibrary` | 图书元数据与借阅入口 |
|
||||||
|
| OpenStax 开放教材 | `openstax` | 开放许可教材与 PDF 全文 |
|
||||||
|
| 开放教材图书馆 | `opentextbook` | 开放教材目录与授权获取入口 |
|
||||||
|
| 中文维基文库 | `wikisource-zh` | 中文经典与公共领域作品 |
|
||||||
|
| 英文维基文库 | `wikisource-en` | 英文经典与公共领域作品 |
|
||||||
| DOAJ 开放期刊 | `doaj` | 开放获取期刊论文 |
|
| DOAJ 开放期刊 | `doaj` | 开放获取期刊论文 |
|
||||||
| PMC 生物医学 | `pmc` | PubMed Central 全文 |
|
| PMC 生物医学 | `pmc` | PubMed Central 全文 |
|
||||||
| bioRxiv 预印本 | `biorxiv` | 仅浏览最新列表,不支持关键词搜索 |
|
| bioRxiv 预印本 | `biorxiv` | 仅浏览最新列表,不支持关键词搜索 |
|
||||||
@@ -38,104 +74,11 @@
|
|||||||
2. 双击目录中的 `PeopleLib.exe`。
|
2. 双击目录中的 `PeopleLib.exe`。
|
||||||
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。
|
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。
|
||||||
|
|
||||||
当前版本为 **1.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
|
当前版本为 **2.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
|
||||||
|
|
||||||
## 源码运行与打包
|
## 开发
|
||||||
|
|
||||||
源码开发需要 Node.js 18+:
|
源码运行、打包发布、代理与账号配置、数据位置、项目结构与测试说明见源码仓库中的 `BUILD.md`。本仓库只发布二进制与使用说明。
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
生成 Windows 免安装版到 `dist/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run portable
|
|
||||||
```
|
|
||||||
|
|
||||||
发布时将完整的 `dist/PeopleLib-1.1.0/` 目录压缩,上传到 GitHub Release,并使用 `v1.1.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
|
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
### 代理
|
|
||||||
|
|
||||||
在应用内「设置」填写代理地址,例如 `http://127.0.0.1:7897`。留空表示直连。配置会持久化,重启后仍生效,并应用于所有数据源请求与封面加载。
|
|
||||||
|
|
||||||
网络受限环境下,多数数据源需要代理才能访问。
|
|
||||||
|
|
||||||
### Z-Library 账号
|
|
||||||
|
|
||||||
「设置」中填入邮箱与密码即可登录。登录后可获取下载直链(免费账号有每日下载额度限制)。
|
|
||||||
|
|
||||||
> 凭据以 base64 混淆后保存在本地 `zlib-auth.json`,**这只是防止肉眼直读,不是加密**。请勿在不受信任的机器上使用。
|
|
||||||
|
|
||||||
## 数据位置
|
|
||||||
|
|
||||||
| 模式 | 路径 |
|
|
||||||
|---|---|
|
|
||||||
| 开发运行 | `%APPDATA%/PeopleLib`(Windows) |
|
|
||||||
| 打包运行 | 可执行文件同级的 `data/` 目录 |
|
|
||||||
|
|
||||||
该目录包含:
|
|
||||||
|
|
||||||
- `library.json` 书库索引
|
|
||||||
- `settings.json` 应用设置(代理等)
|
|
||||||
- `zlib-auth.json` Z-Library 凭据与会话
|
|
||||||
- `covers/` 封面缓存
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
main.js 主进程:窗口、IPC、代理与证书处理、文件下载
|
|
||||||
preload.js 渲染进程 API 桥接
|
|
||||||
src/
|
|
||||||
settings.js 设置持久化
|
|
||||||
library/store.js 书库存储
|
|
||||||
sources/
|
|
||||||
index.js 数据源注册表
|
|
||||||
http.js 统一 HTTP 层:超时、Cookie、代理
|
|
||||||
mirror.js 镜像故障转移
|
|
||||||
zlib-auth.js Z-Library 凭据存储
|
|
||||||
<source>.js 各数据源实现
|
|
||||||
ui/ 渲染进程界面
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据源接口
|
|
||||||
|
|
||||||
每个数据源模块导出以下结构:
|
|
||||||
|
|
||||||
```js
|
|
||||||
module.exports = {
|
|
||||||
id: 'example',
|
|
||||||
name: '示例源',
|
|
||||||
supportsSearch: true,
|
|
||||||
|
|
||||||
async list(page) // 浏览:{ items, maxPage, page }
|
|
||||||
async search(keyword, page) // 搜索:{ items, maxPage, page }
|
|
||||||
async detail(postId) // 详情:{ title, authors, cover, tags, brief, links }
|
|
||||||
async download(postId) // 下载:{ files, links }
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
新增数据源只需实现该接口,并在 `src/sources/index.js` 中注册。
|
|
||||||
|
|
||||||
### 网络层说明
|
|
||||||
|
|
||||||
- 主进程使用 Electron 的 `net.fetch`(Chromium 网络栈),代理通过 `session.setProxy` 生效
|
|
||||||
- 纯 Node 环境回退到 undici `ProxyAgent`(Electron 下不启用,该组合存在兼容问题)
|
|
||||||
- 所有请求默认 15 秒超时,可按调用点覆盖
|
|
||||||
- 失效镜像进入 5 分钟冷却,之后自动重试,避免站点恢复后被永久跳过
|
|
||||||
- **应用启动时全局忽略 TLS 证书错误**。这是为了兼容大量使用自签名或过期证书的镜像站点,代价是失去对中间人攻击的防护,请仅在受信任的网络环境中使用。
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx electron test-search.js --proxy http://127.0.0.1:7897
|
|
||||||
```
|
|
||||||
|
|
||||||
对主要数据源依次执行搜索、详情、下载链路,输出每一步耗时与结果。
|
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// Linux 打包:产出 dist/PeopleLib-linux-<arch>.tar.gz。
|
||||||
|
// 直接把官方 zip 里的条目转写进 tar,不落地中间目录:Linux 的可执行位存在
|
||||||
|
// zip 的 external attributes 里,先解到 NTFS 再打包会把这些位全部丢掉,
|
||||||
|
// 产物解压后 PeopleLib 与 chrome-sandbox 都不可执行。因此本脚本在任意平台
|
||||||
|
// 都能构建 x64 与 arm64 包。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||||
|
const SUPPORTED_ARCHES = ['x64', 'arm64'];
|
||||||
|
|
||||||
|
// 固定时间戳让同一份源码重复构建得到逐字节一致的产物,便于用哈希核对发布件。
|
||||||
|
const MTIME = Number(process.env.SOURCE_DATE_EPOCH) || 1735689600;
|
||||||
|
|
||||||
|
const KEEP_LOCALES = new Set(['zh-CN.pak', 'en-US.pak']);
|
||||||
|
// 无扩展名的可执行文件与 .so 之外,这几个也必须带执行位
|
||||||
|
const FORCE_EXECUTABLE = new Set(['chrome-sandbox', 'chrome_crashpad_handler']);
|
||||||
|
|
||||||
|
function parseArch(argv) {
|
||||||
|
const index = argv.indexOf('--arch');
|
||||||
|
const value = index >= 0 ? argv[index + 1] : process.arch;
|
||||||
|
if (!SUPPORTED_ARCHES.includes(value)) {
|
||||||
|
throw new Error(`不支持的架构:${value || '未指定'}(可选 ${SUPPORTED_ARCHES.join(' / ')})`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd, args) {
|
||||||
|
const result = spawnSync(cmd, args, { stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`${cmd} 失败(退出码 ${result.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方 zip 只在需要时下载一次,缓存在 node_modules/.cache 下按版本和架构分目录
|
||||||
|
function ensureRuntimeZip(arch) {
|
||||||
|
const version = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
|
||||||
|
if (!version) throw new Error('package.json 未锁定 electron 版本');
|
||||||
|
const cacheDir = path.join(ROOT, 'node_modules', '.cache', `electron-linux-${arch}`, version);
|
||||||
|
const zip = path.join(cacheDir, 'electron.zip');
|
||||||
|
if (fs.existsSync(zip) && fs.statSync(zip).size > 0) return zip;
|
||||||
|
|
||||||
|
const mirror = process.env.ELECTRON_MIRROR
|
||||||
|
|| process.env.npm_config_electron_mirror
|
||||||
|
|| 'https://npmmirror.com/mirrors/electron/';
|
||||||
|
const url = `${mirror.replace(/\/?$/, '/')}v${version}/electron-v${version}-linux-${arch}.zip`;
|
||||||
|
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
const partial = `${zip}.download`;
|
||||||
|
fs.rmSync(partial, { force: true });
|
||||||
|
console.log(`下载 Electron ${version} (linux-${arch})...`);
|
||||||
|
run('curl', ['-fSL', '--retry', '3', '-o', partial, url]);
|
||||||
|
fs.renameSync(partial, zip);
|
||||||
|
return zip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutableName(name) {
|
||||||
|
const base = path.posix.basename(name);
|
||||||
|
if (FORCE_EXECUTABLE.has(base)) return true;
|
||||||
|
if (/\.so(\.\d+)*$/.test(base)) return true;
|
||||||
|
return !base.includes('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// jszip 会把 external attributes 的高 16 位解析成 unixPermissions;
|
||||||
|
// 个别条目缺失时按文件名兜底,宁可多给执行位也不能让主程序起不来。
|
||||||
|
function modeOf(entry, name) {
|
||||||
|
const raw = entry.unixPermissions;
|
||||||
|
const parsed = typeof raw === 'number' ? raw & 0o7777 : 0;
|
||||||
|
if (parsed) return parsed;
|
||||||
|
return isExecutableName(name) ? 0o755 : 0o644;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRuntimeEntries(zipPath, arch) {
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
const zip = await JSZip.loadAsync(fs.readFileSync(zipPath));
|
||||||
|
const entries = [];
|
||||||
|
let sawElectron = false;
|
||||||
|
|
||||||
|
for (const name of Object.keys(zip.files)) {
|
||||||
|
const entry = zip.files[name];
|
||||||
|
if (entry.dir) continue;
|
||||||
|
if (name.startsWith('locales/') && !KEEP_LOCALES.has(path.posix.basename(name))) continue;
|
||||||
|
if (name === 'resources/default_app.asar') continue;
|
||||||
|
|
||||||
|
// Electron Linux 包不该有符号链接;真出现了要显式失败,静默展开成副本
|
||||||
|
// 会让产物体积翻倍且行为不可预期。
|
||||||
|
const unixMode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions : 0;
|
||||||
|
if ((unixMode & 0o170000) === 0o120000) {
|
||||||
|
throw new Error(`运行时包含未预期的符号链接:${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = name === 'electron' ? PRODUCT : name;
|
||||||
|
if (name === 'electron') sawElectron = true;
|
||||||
|
entries.push({
|
||||||
|
name: target,
|
||||||
|
mode: name === 'electron' ? 0o755 : modeOf(entry, name),
|
||||||
|
data: await entry.async('nodebuffer')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawElectron) throw new Error(`Electron 运行时缺少主可执行文件(linux-${arch})`);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipDevFiles(e) {
|
||||||
|
const name = e.name.toLowerCase();
|
||||||
|
if (e.isDirectory()) return false;
|
||||||
|
if (/\.(d\.ts|d\.ts\.map|ts|tsx|map|flow)$/.test(name)) return true;
|
||||||
|
if (/^(readme|changelog|history|license|licence|notice|authors|contributing|security)/.test(name)) return true;
|
||||||
|
if (/\.(md|markdown)$/.test(name)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDir(src, prefix, skip) {
|
||||||
|
const out = [];
|
||||||
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
if (skip && skip(e)) continue;
|
||||||
|
const from = path.join(src, e.name);
|
||||||
|
const to = path.posix.join(prefix, e.name);
|
||||||
|
if (e.isDirectory()) out.push(...collectDir(from, to, skip));
|
||||||
|
else out.push({ name: to, mode: 0o644, data: fs.readFileSync(from) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFile(src, name, mode = 0o644) {
|
||||||
|
return { name, mode, data: fs.readFileSync(src) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appEntries() {
|
||||||
|
const app = 'resources/app';
|
||||||
|
const entries = [
|
||||||
|
collectFile(path.join(ROOT, 'main.js'), `${app}/main.js`),
|
||||||
|
collectFile(path.join(ROOT, 'preload.js'), `${app}/preload.js`),
|
||||||
|
...collectDir(path.join(ROOT, 'src'), `${app}/src`, (e) => e.name.startsWith('_test'))
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||||
|
entries.push(collectFile(path.join(ROOT, 'icons', 'dist', name), `${app}/icons/dist/${name}`));
|
||||||
|
}
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
// Linux 的 BrowserWindow 图标用 PNG:256 供窗口,32 供渲染层复用
|
||||||
|
for (const size of [32, 256]) {
|
||||||
|
entries.push(collectFile(
|
||||||
|
path.join(ROOT, 'icons', 'dist', theme, `icon-${size}.png`),
|
||||||
|
`${app}/icons/dist/${theme}/icon-${size}.png`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const undici = path.join(ROOT, 'node_modules', 'undici');
|
||||||
|
for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) {
|
||||||
|
const file = path.join(undici, name);
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
entries.push(collectFile(file, `${app}/node_modules/undici/${name}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push(...collectDir(
|
||||||
|
path.join(undici, 'lib'), `${app}/node_modules/undici/lib`, skipDevFiles
|
||||||
|
));
|
||||||
|
|
||||||
|
const foliate = path.join(ROOT, 'node_modules', 'foliate-js');
|
||||||
|
for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
|
||||||
|
entries.push(collectFile(path.join(foliate, name), `${app}/node_modules/foliate-js/${name}`));
|
||||||
|
}
|
||||||
|
entries.push(collectFile(
|
||||||
|
path.join(foliate, 'vendor', 'fflate.js'),
|
||||||
|
`${app}/node_modules/foliate-js/vendor/fflate.js`
|
||||||
|
));
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name: `${app}/package.json`,
|
||||||
|
mode: 0o644,
|
||||||
|
data: Buffer.from(`${JSON.stringify({
|
||||||
|
name: pkg.name, version: pkg.version, description: pkg.description,
|
||||||
|
productName: PRODUCT,
|
||||||
|
main: 'main.js', author: pkg.author, license: pkg.license,
|
||||||
|
dependencies: {
|
||||||
|
undici: pkg.dependencies.undici,
|
||||||
|
'foliate-js': pkg.dependencies['foliate-js']
|
||||||
|
}
|
||||||
|
}, null, 2)}\n`, 'utf8')
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面集成留给用户自行安装,这里只给出可直接使用的启动脚本与 .desktop 模板。
|
||||||
|
// 不加 --no-sandbox:多数发行版开启了非特权用户命名空间,Electron 能正常起沙箱;
|
||||||
|
// 内核禁用该特性时才需要按 BUILD.md 给 chrome-sandbox 补 setuid。
|
||||||
|
function launcherEntries() {
|
||||||
|
const launcher = `#!/bin/sh
|
||||||
|
set -e
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
exec "$HERE/${PRODUCT}" "$@"
|
||||||
|
`;
|
||||||
|
const desktop = `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=${PRODUCT}
|
||||||
|
Comment=${pkg.description}
|
||||||
|
Exec=${PRODUCT}.sh %U
|
||||||
|
Icon=${PRODUCT.toLowerCase()}
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Viewer;
|
||||||
|
`;
|
||||||
|
return [
|
||||||
|
{ name: `${PRODUCT}.sh`, mode: 0o755, data: Buffer.from(launcher, 'utf8') },
|
||||||
|
{ name: `${PRODUCT}.desktop`, mode: 0o644, data: Buffer.from(desktop, 'utf8') },
|
||||||
|
collectFile(path.join(ROOT, 'icons', 'dist', 'dark', 'icon-256.png'), `${PRODUCT.toLowerCase()}.png`)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function octal(value, length) {
|
||||||
|
// tar 的数字字段是定长八进制串,末位留给 NUL
|
||||||
|
return Buffer.from(value.toString(8).padStart(length - 1, '0') + '\0', 'ascii');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarHeader({ name, mode, size, typeflag }) {
|
||||||
|
const header = Buffer.alloc(512);
|
||||||
|
header.write(name, 0, 100, 'utf8');
|
||||||
|
octal(mode & 0o7777, 8).copy(header, 100);
|
||||||
|
octal(0, 8).copy(header, 108); // uid
|
||||||
|
octal(0, 8).copy(header, 116); // gid
|
||||||
|
octal(size, 12).copy(header, 124);
|
||||||
|
octal(MTIME, 12).copy(header, 136);
|
||||||
|
header.write(' ', 148, 8, 'ascii'); // 计算校验和时该字段视为空格
|
||||||
|
header.write(typeflag, 156, 1, 'ascii');
|
||||||
|
header.write('ustar\0', 257, 6, 'ascii');
|
||||||
|
header.write('00', 263, 2, 'ascii');
|
||||||
|
header.write('root', 265, 32, 'ascii');
|
||||||
|
header.write('root', 297, 32, 'ascii');
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (const byte of header) sum += byte;
|
||||||
|
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padding(size) {
|
||||||
|
const remainder = size % 512;
|
||||||
|
return remainder ? Buffer.alloc(512 - remainder) : Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径超过 100 字节时用 PAX 扩展头承载完整路径。ustar 的 prefix 字段只能在
|
||||||
|
// 斜杠处切分,undici 与 vendor 里的深层路径切不出合法组合,必须走 PAX。
|
||||||
|
function paxRecords(fullName) {
|
||||||
|
const record = (key, value) => {
|
||||||
|
const body = ` ${key}=${value}\n`;
|
||||||
|
let length = Buffer.byteLength(body) + 1;
|
||||||
|
while (Buffer.byteLength(`${length}${body}`) !== length) length += 1;
|
||||||
|
return Buffer.from(`${length}${body}`, 'utf8');
|
||||||
|
};
|
||||||
|
return record('path', fullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarEntry(fullName, mode, data, typeflag = '0') {
|
||||||
|
const chunks = [];
|
||||||
|
const nameBytes = Buffer.byteLength(fullName, 'utf8');
|
||||||
|
|
||||||
|
if (nameBytes > 100) {
|
||||||
|
const records = paxRecords(fullName);
|
||||||
|
chunks.push(tarHeader({
|
||||||
|
name: `PaxHeader/${path.posix.basename(fullName).slice(0, 80)}`,
|
||||||
|
mode: 0o644,
|
||||||
|
size: records.length,
|
||||||
|
typeflag: 'x'
|
||||||
|
}));
|
||||||
|
chunks.push(records, padding(records.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(tarHeader({
|
||||||
|
// 超长路径已由 PAX 头给出,这里的截断名只是给不支持 PAX 的工具看的回退值
|
||||||
|
name: nameBytes > 100 ? fullName.slice(-100) : fullName,
|
||||||
|
mode,
|
||||||
|
size: data.length,
|
||||||
|
typeflag
|
||||||
|
}));
|
||||||
|
chunks.push(data, padding(data.length));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarGz(entries, root, outFile) {
|
||||||
|
const seen = new Set();
|
||||||
|
const chunks = [];
|
||||||
|
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||||
|
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const full = path.posix.join(root, entry.name);
|
||||||
|
if (seen.has(full)) throw new Error(`打包条目重复:${full}`);
|
||||||
|
seen.add(full);
|
||||||
|
chunks.push(tarEntry(full, entry.mode, entry.data));
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.alloc(1024)); // tar 以两个空块收尾
|
||||||
|
|
||||||
|
const gz = zlib.gzipSync(Buffer.concat(chunks), { level: 9, mtime: 0 });
|
||||||
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||||
|
fs.writeFileSync(outFile, gz);
|
||||||
|
return gz.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
const arch = parseArch(process.argv.slice(2));
|
||||||
|
const target = `${PRODUCT}-linux-${arch}`;
|
||||||
|
const outFile = path.join(ROOT, 'dist', `${target}.tar.gz`);
|
||||||
|
|
||||||
|
const zip = ensureRuntimeZip(arch);
|
||||||
|
console.log('读取 Electron 运行时(保留可执行位)...');
|
||||||
|
const runtime = await readRuntimeEntries(zip, arch);
|
||||||
|
|
||||||
|
console.log('组装 app 源码...');
|
||||||
|
const entries = [...runtime, ...appEntries(), ...launcherEntries()];
|
||||||
|
|
||||||
|
console.log('生成 tar.gz...');
|
||||||
|
const size = writeTarGz(entries, target, outFile);
|
||||||
|
|
||||||
|
console.log('\n构建完成:');
|
||||||
|
console.log(' 架构:', arch);
|
||||||
|
console.log(' 产物:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`);
|
||||||
|
console.log(' 解压后运行:', `./${target}/${PRODUCT}.sh`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseArch, isExecutableName, modeOf, tarEntry, writeTarGz };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
build().catch((error) => {
|
||||||
|
console.error('构建失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// macOS arm64 打包:产出 PeopleLib.app 与 DMG。
|
||||||
|
// 必须在 macOS 上运行:DMG 由 hdiutil 生成,且 Apple Silicon 内核会拒绝执行
|
||||||
|
// 未签名的二进制,改名和塞文件都会让 Electron 原始签名失效,必须重新签。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||||
|
const APP_ID = 'com.peoplelib.client';
|
||||||
|
const ARCH = 'arm64';
|
||||||
|
const TARGET = `${PRODUCT}-macos-${ARCH}`;
|
||||||
|
const OUT = path.join(ROOT, 'dist', TARGET);
|
||||||
|
const APP_BUNDLE = path.join(OUT, `${PRODUCT}.app`);
|
||||||
|
const CONTENTS = path.join(APP_BUNDLE, 'Contents');
|
||||||
|
const RESOURCES = path.join(CONTENTS, 'Resources');
|
||||||
|
const APP = path.join(RESOURCES, 'app');
|
||||||
|
const DMG = path.join(ROOT, 'dist', `${TARGET}.dmg`);
|
||||||
|
|
||||||
|
const KEEP_LOCALES = new Set(['zh_CN', 'en', 'en_GB', 'zh_TW']);
|
||||||
|
|
||||||
|
function run(cmd, args, options) {
|
||||||
|
const result = spawnSync(cmd, args, { stdio: 'inherit', ...options });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`${cmd} 失败(退出码 ${result.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(cmd, args) {
|
||||||
|
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
return { status: result.status, out: `${result.stdout || ''}${result.stderr || ''}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireMac() {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
throw new Error(
|
||||||
|
'macOS 打包只能在 macOS 上执行:DMG 需要 hdiutil,且 Apple Silicon 要求重新签名(codesign)。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const tool of ['hdiutil', 'codesign', 'ditto']) {
|
||||||
|
if (capture('which', [tool]).status !== 0) throw new Error(`缺少 ${tool},请先安装 Xcode 命令行工具`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
|
||||||
|
|
||||||
|
function copyDir(src, dst, skip) {
|
||||||
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
if (skip && skip(e)) continue;
|
||||||
|
const s = path.join(src, e.name);
|
||||||
|
const d = path.join(dst, e.name);
|
||||||
|
if (e.isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(s), d);
|
||||||
|
else if (e.isDirectory()) copyDir(s, d, skip);
|
||||||
|
else fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipDevFiles(e) {
|
||||||
|
const name = e.name.toLowerCase();
|
||||||
|
if (e.isDirectory()) return false;
|
||||||
|
if (/\.(d\.ts|d\.ts\.map|ts|tsx|map|flow)$/.test(name)) return true;
|
||||||
|
if (/^(readme|changelog|history|license|licence|notice|authors|contributing|security)/.test(name)) return true;
|
||||||
|
if (/\.(md|markdown)$/.test(name)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUndici(dst) {
|
||||||
|
const src = path.join(ROOT, 'node_modules', 'undici');
|
||||||
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
|
for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) {
|
||||||
|
const file = path.join(src, name);
|
||||||
|
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dst, name));
|
||||||
|
}
|
||||||
|
copyDir(path.join(src, 'lib'), path.join(dst, 'lib'), skipDevFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFoliate(dst) {
|
||||||
|
const src = path.join(ROOT, 'node_modules', 'foliate-js');
|
||||||
|
fs.mkdirSync(path.join(dst, 'vendor'), { recursive: true });
|
||||||
|
for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
|
||||||
|
fs.copyFileSync(path.join(src, name), path.join(dst, name));
|
||||||
|
}
|
||||||
|
fs.copyFileSync(path.join(src, 'vendor', 'fflate.js'), path.join(dst, 'vendor', 'fflate.js'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方 zip 里 Electron Framework 带符号链接,必须用 ditto 解压;
|
||||||
|
// unzip 或 Node 解压会把链接展开成副本,签名随即失效。
|
||||||
|
function ensureRuntime() {
|
||||||
|
const version = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
|
||||||
|
if (!version) throw new Error('package.json 未锁定 electron 版本');
|
||||||
|
const cacheDir = path.join(ROOT, 'node_modules', '.cache', 'electron-darwin-arm64', version);
|
||||||
|
const source = path.join(cacheDir, 'Electron.app');
|
||||||
|
if (fs.existsSync(path.join(source, 'Contents', 'MacOS', 'Electron'))) return source;
|
||||||
|
|
||||||
|
const mirror = process.env.ELECTRON_MIRROR
|
||||||
|
|| process.env.npm_config_electron_mirror
|
||||||
|
|| 'https://npmmirror.com/mirrors/electron/';
|
||||||
|
const url = `${mirror.replace(/\/?$/, '/')}v${version}/electron-v${version}-darwin-${ARCH}.zip`;
|
||||||
|
const zip = path.join(cacheDir, 'electron.zip');
|
||||||
|
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
console.log(`下载 Electron ${version} (darwin-${ARCH})...`);
|
||||||
|
run('curl', ['-fSL', '--retry', '3', '-o', zip, url]);
|
||||||
|
console.log('解压运行时(ditto 保留符号链接与权限)...');
|
||||||
|
run('ditto', ['-x', '-k', zip, cacheDir]);
|
||||||
|
fs.rmSync(zip, { force: true });
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(source, 'Contents', 'MacOS', 'Electron'))) {
|
||||||
|
throw new Error('Electron 运行时解压结果无效');
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneLproj(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
if (!name.endsWith('.lproj')) continue;
|
||||||
|
if (KEEP_LOCALES.has(name.slice(0, -6))) continue;
|
||||||
|
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plistString(key, value) {
|
||||||
|
return `\t<key>${key}</key>\n\t<string>${value}</string>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInfoPlist() {
|
||||||
|
const file = path.join(CONTENTS, 'Info.plist');
|
||||||
|
let plist = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
const replacements = {
|
||||||
|
CFBundleDisplayName: PRODUCT,
|
||||||
|
CFBundleExecutable: PRODUCT,
|
||||||
|
CFBundleName: PRODUCT,
|
||||||
|
CFBundleIdentifier: APP_ID,
|
||||||
|
CFBundleIconFile: 'app.icns',
|
||||||
|
CFBundleShortVersionString: pkg.version,
|
||||||
|
CFBundleVersion: pkg.version,
|
||||||
|
LSApplicationCategoryType: 'public.app-category.productivity'
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(replacements)) {
|
||||||
|
const re = new RegExp(`\\t<key>${key}</key>\\n\\t<string>[^<]*</string>`);
|
||||||
|
if (!re.test(plist)) throw new Error(`Info.plist 缺少 ${key}`);
|
||||||
|
plist = plist.replace(re, plistString(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重打包后 asar 哈希不再匹配,保留该字段会让 Electron 启动即报完整性错误。
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>ElectronAsarIntegrity<\/key>\n\t<dict>[\s\S]*?\n\t<\/dict>\n/,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, plist);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHelperPlists() {
|
||||||
|
const frameworks = path.join(CONTENTS, 'Frameworks');
|
||||||
|
for (const entry of fs.readdirSync(frameworks)) {
|
||||||
|
if (!entry.endsWith('.app')) continue;
|
||||||
|
const suffix = /\(([^)]+)\)/.exec(entry);
|
||||||
|
const label = suffix ? ` (${suffix[1]})` : '';
|
||||||
|
const oldName = entry.slice(0, -4);
|
||||||
|
const newName = `${PRODUCT} Helper${label}`;
|
||||||
|
const appDir = path.join(frameworks, entry);
|
||||||
|
const file = path.join(appDir, 'Contents', 'Info.plist');
|
||||||
|
|
||||||
|
let plist = fs.readFileSync(file, 'utf8');
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>CFBundleIdentifier<\/key>\n\t<string>[^<]*<\/string>/,
|
||||||
|
plistString('CFBundleIdentifier', `${APP_ID}.helper`)
|
||||||
|
);
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>CFBundleName<\/key>\n\t<string>[^<]*<\/string>/,
|
||||||
|
plistString('CFBundleName', newName)
|
||||||
|
);
|
||||||
|
// 官方 helper plist 没有 CFBundleExecutable,靠 bundle 名推断可执行文件名,
|
||||||
|
// 改名后必须显式写死,否则 helper 进程起不来,界面一片空白。
|
||||||
|
if (!/CFBundleExecutable/.test(plist)) {
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>CFBundleIdentifier<\/key>/,
|
||||||
|
`${plistString('CFBundleExecutable', newName)}\n\t<key>CFBundleIdentifier</key>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, plist);
|
||||||
|
|
||||||
|
fs.renameSync(
|
||||||
|
path.join(appDir, 'Contents', 'MacOS', oldName),
|
||||||
|
path.join(appDir, 'Contents', 'MacOS', newName)
|
||||||
|
);
|
||||||
|
fs.renameSync(appDir, path.join(frameworks, `${newName}.app`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须由内向外签:嵌套可执行文件 -> helper -> framework -> 外层 .app。
|
||||||
|
// 顺序反了会让外层签名立刻失效,Apple Silicon 上表现为应用被内核直接杀掉。
|
||||||
|
function signBundle() {
|
||||||
|
const frameworks = path.join(CONTENTS, 'Frameworks');
|
||||||
|
const targets = [];
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(frameworks)) {
|
||||||
|
const full = path.join(frameworks, entry);
|
||||||
|
if (entry.endsWith('.app')) {
|
||||||
|
targets.push(path.join(full, 'Contents', 'MacOS', entry.slice(0, -4)));
|
||||||
|
targets.push(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.endsWith('.framework')) continue;
|
||||||
|
|
||||||
|
// Squirrel 在 Resources 里藏了独立的 ShipIt 可执行文件,
|
||||||
|
// 不单独签会让 --deep --strict 校验失败。
|
||||||
|
const shipIt = path.join(full, 'Versions', 'A', 'Resources', 'ShipIt');
|
||||||
|
if (fs.existsSync(shipIt)) targets.push(shipIt);
|
||||||
|
|
||||||
|
// 带版本的 framework 要签 Versions/A,直接签 .framework 顶层可能被判定
|
||||||
|
// 为 bundle format unrecognized。
|
||||||
|
const versioned = path.join(full, 'Versions', 'A');
|
||||||
|
targets.push(fs.existsSync(versioned) ? versioned : full);
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.push(APP_BUNDLE);
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
run('codesign', ['--force', '--sign', '-', '--timestamp=none', target]);
|
||||||
|
}
|
||||||
|
run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', APP_BUNDLE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDmg() {
|
||||||
|
rimraf(DMG);
|
||||||
|
const staging = path.join(ROOT, 'dist', `${TARGET}-dmg`);
|
||||||
|
rimraf(staging);
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
run('ditto', [APP_BUNDLE, path.join(staging, `${PRODUCT}.app`)]);
|
||||||
|
fs.symlinkSync('/Applications', path.join(staging, 'Applications'));
|
||||||
|
|
||||||
|
run('hdiutil', [
|
||||||
|
'create', '-volname', `${PRODUCT} ${pkg.version}`,
|
||||||
|
'-srcfolder', staging, '-ov', '-format', 'UDZO', '-fs', 'HFS+', DMG
|
||||||
|
]);
|
||||||
|
rimraf(staging);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
requireMac();
|
||||||
|
const runtime = ensureRuntime();
|
||||||
|
|
||||||
|
console.log('清理输出目录...');
|
||||||
|
rimraf(OUT);
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
console.log('复制 Electron.app...');
|
||||||
|
run('ditto', [runtime, APP_BUNDLE]);
|
||||||
|
|
||||||
|
console.log('精简语言包...');
|
||||||
|
pruneLproj(RESOURCES);
|
||||||
|
pruneLproj(path.join(
|
||||||
|
CONTENTS, 'Frameworks', 'Electron Framework.framework', 'Versions', 'A', 'Resources'
|
||||||
|
));
|
||||||
|
|
||||||
|
console.log('应用图标与 Info.plist...');
|
||||||
|
fs.rmSync(path.join(RESOURCES, 'electron.icns'), { force: true });
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'icons', 'dist', 'book-ai-dark.icns'), path.join(RESOURCES, 'app.icns'));
|
||||||
|
writeInfoPlist();
|
||||||
|
writeHelperPlists();
|
||||||
|
|
||||||
|
console.log('重命名主可执行文件...');
|
||||||
|
fs.renameSync(path.join(CONTENTS, 'MacOS', 'Electron'), path.join(CONTENTS, 'MacOS', PRODUCT));
|
||||||
|
fs.rmSync(path.join(RESOURCES, 'default_app.asar'), { force: true });
|
||||||
|
|
||||||
|
console.log('组装 app 源码...');
|
||||||
|
fs.mkdirSync(APP, { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
|
||||||
|
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
|
||||||
|
const iconDir = path.join(APP, 'icons', 'dist');
|
||||||
|
fs.mkdirSync(iconDir, { recursive: true });
|
||||||
|
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'icons', 'dist', name), path.join(iconDir, name));
|
||||||
|
}
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const themeDir = path.join(iconDir, theme);
|
||||||
|
fs.mkdirSync(themeDir, { recursive: true });
|
||||||
|
// macOS 的 BrowserWindow 图标用 PNG;256 供窗口,32 供渲染层复用
|
||||||
|
for (const size of [32, 256]) {
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(ROOT, 'icons', 'dist', theme, `icon-${size}.png`),
|
||||||
|
path.join(themeDir, `icon-${size}.png`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copyUndici(path.join(APP, 'node_modules', 'undici'));
|
||||||
|
copyFoliate(path.join(APP, 'node_modules', 'foliate-js'));
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
|
||||||
|
name: pkg.name, version: pkg.version, description: pkg.description,
|
||||||
|
productName: PRODUCT,
|
||||||
|
main: 'main.js', author: pkg.author, license: pkg.license,
|
||||||
|
dependencies: {
|
||||||
|
undici: pkg.dependencies.undici,
|
||||||
|
'foliate-js': pkg.dependencies['foliate-js']
|
||||||
|
}
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
console.log('Ad-hoc 签名...');
|
||||||
|
signBundle();
|
||||||
|
|
||||||
|
console.log('生成 DMG...');
|
||||||
|
buildDmg();
|
||||||
|
|
||||||
|
console.log('\n构建完成:');
|
||||||
|
console.log(' 应用:', APP_BUNDLE);
|
||||||
|
console.log(' DMG :', DMG, `(${(fs.statSync(DMG).size / 1024 / 1024).toFixed(1)} MB)`);
|
||||||
|
console.log('\n首次打开:右键点按图标选“打开”,或执行');
|
||||||
|
console.log(` xattr -dr com.apple.quarantine "/Applications/${PRODUCT}.app"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch((error) => {
|
||||||
|
console.error('构建失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -1,15 +1,86 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
const pkg = require('./package.json');
|
const pkg = require('./package.json');
|
||||||
const OUT = process.env.PEOPLELIB_OUT_DIR
|
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||||
|
const TARGET = `${PRODUCT}-windows-x64`;
|
||||||
|
const REQUESTED_OUT = process.env.PEOPLELIB_OUT_DIR
|
||||||
? path.resolve(process.env.PEOPLELIB_OUT_DIR)
|
? path.resolve(process.env.PEOPLELIB_OUT_DIR)
|
||||||
: path.join(ROOT, 'dist', `PeopleLib-${pkg.version}`);
|
: path.join(ROOT, 'dist', TARGET);
|
||||||
|
const OUT = REQUESTED_OUT;
|
||||||
const APP = path.join(OUT, 'resources', 'app');
|
const APP = path.join(OUT, 'resources', 'app');
|
||||||
const PRODUCT = 'PeopleLib';
|
|
||||||
|
|
||||||
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
|
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
|
||||||
|
function clearOutput(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
const executable = path.join(dir, `${PRODUCT}.exe`);
|
||||||
|
const probe = executable + '.build-lock-check';
|
||||||
|
if (fs.existsSync(executable)) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(executable, probe);
|
||||||
|
fs.renameSync(probe, executable);
|
||||||
|
} catch (error) {
|
||||||
|
if (!fs.existsSync(executable) && fs.existsSync(probe)) {
|
||||||
|
try { fs.renameSync(probe, executable); } catch (restoreError) { /* report the original lock error */ }
|
||||||
|
}
|
||||||
|
if (error && (error.code === 'EPERM' || error.code === 'EBUSY' || error.code === 'EACCES')) {
|
||||||
|
throw new Error(`无法清理固定输出目录 ${dir},请先关闭其中正在运行的 ${PRODUCT}.exe 后重试`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === 'data') continue;
|
||||||
|
const target = path.join(dir, entry.name);
|
||||||
|
try {
|
||||||
|
fs.rmSync(target, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error && (error.code === 'EPERM' || error.code === 'EBUSY')) {
|
||||||
|
throw new Error(`无法清理固定输出目录 ${dir},请先关闭其中正在运行的 ${PRODUCT}.exe 后重试`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureElectronRuntime() {
|
||||||
|
const electronDir = path.join(ROOT, 'node_modules', 'electron');
|
||||||
|
const distDir = path.join(electronDir, 'dist');
|
||||||
|
const executable = path.join(distDir, 'electron.exe');
|
||||||
|
const versionFile = path.join(distDir, 'version');
|
||||||
|
const expected = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
|
||||||
|
const installed = fs.existsSync(versionFile)
|
||||||
|
? fs.readFileSync(versionFile, 'utf8').trim().replace(/^v/, '')
|
||||||
|
: '';
|
||||||
|
if (installed === expected && fs.existsSync(executable)) return distDir;
|
||||||
|
|
||||||
|
const installer = path.join(electronDir, 'install.js');
|
||||||
|
if (!fs.existsSync(installer)) throw new Error('缺少 Electron 安装脚本,请先运行 npm ci');
|
||||||
|
console.log(`准备 Electron ${expected} 运行时...`);
|
||||||
|
const result = spawnSync(process.execPath, [installer], {
|
||||||
|
cwd: electronDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR
|
||||||
|
|| process.env.npm_config_electron_mirror
|
||||||
|
|| 'https://npmmirror.com/mirrors/electron/'
|
||||||
|
},
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`Electron 运行时安装失败(退出码 ${result.status})`);
|
||||||
|
|
||||||
|
const prepared = fs.existsSync(versionFile)
|
||||||
|
? fs.readFileSync(versionFile, 'utf8').trim().replace(/^v/, '')
|
||||||
|
: '';
|
||||||
|
if (prepared !== expected || !fs.existsSync(executable)) {
|
||||||
|
throw new Error(`Electron 运行时版本无效(期望 ${expected || '未知'},实际 ${prepared || '缺失'})`);
|
||||||
|
}
|
||||||
|
return distDir;
|
||||||
|
}
|
||||||
|
|
||||||
function copyDir(src, dst, skip) {
|
function copyDir(src, dst, skip) {
|
||||||
fs.mkdirSync(dst, { recursive: true });
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
@@ -47,32 +118,86 @@ function copyUndici(dst) {
|
|||||||
copyDir(path.join(src, 'lib'), path.join(dst, 'lib'), skipDevFiles);
|
copyDir(path.join(src, 'lib'), path.join(dst, 'lib'), skipDevFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('清理输出目录...');
|
function copyFoliate(dst) {
|
||||||
rimraf(OUT);
|
const src = path.join(ROOT, 'node_modules', 'foliate-js');
|
||||||
|
fs.mkdirSync(path.join(dst, 'vendor'), { recursive: true });
|
||||||
|
for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
|
||||||
|
fs.copyFileSync(path.join(src, name), path.join(dst, name));
|
||||||
|
}
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(src, 'vendor', 'fflate.js'),
|
||||||
|
path.join(dst, 'vendor', 'fflate.js')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('复制 Electron 运行时...');
|
async function build() {
|
||||||
copyDir(path.join(ROOT, 'node_modules', 'electron', 'dist'), OUT);
|
const electronDist = ensureElectronRuntime();
|
||||||
|
|
||||||
console.log('精简语言包...');
|
console.log('清理固定输出目录(保留 data)...');
|
||||||
pruneLocales(path.join(OUT, 'locales'));
|
clearOutput(OUT);
|
||||||
|
|
||||||
console.log('重命名可执行文件...');
|
console.log('复制 Electron 运行时...');
|
||||||
fs.renameSync(path.join(OUT, 'electron.exe'), path.join(OUT, PRODUCT + '.exe'));
|
copyDir(electronDist, OUT);
|
||||||
rimraf(path.join(OUT, 'resources', 'default_app.asar'));
|
|
||||||
|
|
||||||
console.log('组装 app 源码...');
|
console.log('精简语言包...');
|
||||||
fs.mkdirSync(APP, { recursive: true });
|
pruneLocales(path.join(OUT, 'locales'));
|
||||||
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
|
|
||||||
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
|
|
||||||
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
|
|
||||||
copyUndici(path.join(APP, 'node_modules', 'undici'));
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
|
console.log('重命名可执行文件...');
|
||||||
name: pkg.name, version: pkg.version, description: pkg.description,
|
const executable = path.join(OUT, PRODUCT + '.exe');
|
||||||
main: 'main.js', author: pkg.author, license: pkg.license,
|
fs.renameSync(path.join(OUT, 'electron.exe'), executable);
|
||||||
dependencies: { undici: pkg.dependencies.undici }
|
rimraf(path.join(OUT, 'resources', 'default_app.asar'));
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
console.log('\n构建完成:');
|
console.log(`应用 ${PRODUCT} 图标...`);
|
||||||
console.log(' 目录:', OUT);
|
const { rcedit } = await import('rcedit');
|
||||||
console.log(' 可执行文件:', path.join(OUT, PRODUCT + '.exe'));
|
await rcedit(executable, {
|
||||||
|
icon: path.join(ROOT, 'icons', 'dist', 'book-ai-dark.ico'),
|
||||||
|
'file-version': pkg.version,
|
||||||
|
'product-version': pkg.version,
|
||||||
|
'version-string': {
|
||||||
|
ProductName: PRODUCT,
|
||||||
|
FileDescription: PRODUCT,
|
||||||
|
InternalName: PRODUCT,
|
||||||
|
OriginalFilename: `${PRODUCT}.exe`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('组装 app 源码...');
|
||||||
|
fs.mkdirSync(APP, { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
|
||||||
|
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
|
||||||
|
const iconDir = path.join(APP, 'icons', 'dist');
|
||||||
|
fs.mkdirSync(iconDir, { recursive: true });
|
||||||
|
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'icons', 'dist', name), path.join(iconDir, name));
|
||||||
|
}
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const themeDir = path.join(iconDir, theme);
|
||||||
|
fs.mkdirSync(themeDir, { recursive: true });
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(ROOT, 'icons', 'dist', theme, 'icon-32.png'),
|
||||||
|
path.join(themeDir, 'icon-32.png')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
copyUndici(path.join(APP, 'node_modules', 'undici'));
|
||||||
|
copyFoliate(path.join(APP, 'node_modules', 'foliate-js'));
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
|
||||||
|
name: pkg.name, version: pkg.version, description: pkg.description,
|
||||||
|
productName: PRODUCT,
|
||||||
|
main: 'main.js', author: pkg.author, license: pkg.license,
|
||||||
|
dependencies: {
|
||||||
|
undici: pkg.dependencies.undici,
|
||||||
|
'foliate-js': pkg.dependencies['foliate-js']
|
||||||
|
}
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
console.log('\n构建完成:');
|
||||||
|
console.log(' 目录:', OUT);
|
||||||
|
console.log(' 可执行文件:', executable);
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch((error) => {
|
||||||
|
console.error('构建失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// 发布打包:调用各平台构建脚本,把产物收敛成带校验和的发布件。
|
||||||
|
//
|
||||||
|
// node build-release.js --platform windows|macos|linux --arch x64|arm64 [--skip-build]
|
||||||
|
// node build-release.js --verify <目录> # 回验下载下来的各平台发布件
|
||||||
|
//
|
||||||
|
// 产物统一落在 dist/release/<platform>-<arch>/,含发布件本体、SHA256SUMS.txt
|
||||||
|
// 与 release-manifest.json。发布流程只上传这个目录,避免把构建中间物或
|
||||||
|
// 本地 data/ 误传到 Release。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const DIST = path.join(ROOT, 'dist');
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||||
|
const VERSION = pkg.version;
|
||||||
|
|
||||||
|
const TARGETS = {
|
||||||
|
'windows-x64': {
|
||||||
|
build: ['build-portable.js'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-windows-x64`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-windows-x64.zip`,
|
||||||
|
pack: packWindowsZip,
|
||||||
|
verify: verifyWindowsZip
|
||||||
|
},
|
||||||
|
'macos-arm64': {
|
||||||
|
build: ['build-mac.js'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-macos-arm64.dmg`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-macos-arm64.dmg`,
|
||||||
|
pack: copyAsset,
|
||||||
|
verify: verifyDmg
|
||||||
|
},
|
||||||
|
'linux-x64': {
|
||||||
|
build: ['build-linux.js', '--arch', 'x64'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-linux-x64.tar.gz`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-linux-x64.tar.gz`,
|
||||||
|
pack: copyAsset,
|
||||||
|
verify: verifyLinuxTarball
|
||||||
|
},
|
||||||
|
'linux-arm64': {
|
||||||
|
build: ['build-linux.js', '--arch', 'arm64'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-linux-arm64.tar.gz`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-linux-arm64.tar.gz`,
|
||||||
|
pack: copyAsset,
|
||||||
|
verify: verifyLinuxTarball
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { skipBuild: false };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const key = argv[i];
|
||||||
|
if (key === '--skip-build') args.skipBuild = true;
|
||||||
|
else if (key === '--platform') args.platform = argv[++i];
|
||||||
|
else if (key === '--arch') args.arch = argv[++i];
|
||||||
|
else if (key === '--verify') args.verify = argv[++i];
|
||||||
|
else throw new Error(`无法识别的参数:${key}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(file) {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
|
||||||
|
|
||||||
|
function runBuild(script) {
|
||||||
|
const result = spawnSync(process.execPath, script, { cwd: ROOT, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`${script[0]} 失败(退出码 ${result.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, prefix = '') {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||||
|
const abs = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...walk(abs, rel));
|
||||||
|
else out.push({ rel, abs });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 便携版把用户书库放在程序同级 data/,本机构建目录里通常是有内容的。
|
||||||
|
// 漏掉这条排除就会把整个书库连同笔记打进公开发布件。
|
||||||
|
function packWindowsZip(source, outFile) {
|
||||||
|
const files = walk(source).filter((f) => !f.rel.startsWith('data/'));
|
||||||
|
if (!files.some((f) => f.rel === `${PRODUCT}.exe`)) {
|
||||||
|
throw new Error(`构建目录缺少 ${PRODUCT}.exe`);
|
||||||
|
}
|
||||||
|
const leaked = files.find((f) => /(^|\/)_test\//.test(f.rel));
|
||||||
|
if (leaked) throw new Error(`构建目录混入测试文件:${leaked.rel}`);
|
||||||
|
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
const zip = new JSZip();
|
||||||
|
const root = `${PRODUCT}-${VERSION}-windows-x64`;
|
||||||
|
for (const file of files.sort((a, b) => (a.rel < b.rel ? -1 : 1))) {
|
||||||
|
zip.file(`${root}/${file.rel}`, fs.readFileSync(file.abs), { date: new Date(0) });
|
||||||
|
}
|
||||||
|
return zip
|
||||||
|
.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } })
|
||||||
|
.then((buffer) => { fs.writeFileSync(outFile, buffer); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAsset(source, outFile) {
|
||||||
|
fs.copyFileSync(source, outFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyWindowsZip(file) {
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
const zip = await JSZip.loadAsync(fs.readFileSync(file));
|
||||||
|
const names = Object.keys(zip.files).map((n) => n.replace(/^[^/]+\//, ''));
|
||||||
|
requireEntries(names, [`${PRODUCT}.exe`, 'resources/app/main.js', 'locales/zh-CN.pak']);
|
||||||
|
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyDmg(file) {
|
||||||
|
const magic = Buffer.alloc(2);
|
||||||
|
const fd = fs.openSync(file, 'r');
|
||||||
|
try { fs.readSync(fd, magic, 0, 2, 0); } finally { fs.closeSync(fd); }
|
||||||
|
// UDZO 镜像是 zlib 压缩块,头两字节为 zlib 魔数;空壳或半截文件在这里就暴露
|
||||||
|
if (magic[0] !== 0x78) throw new Error('DMG 内容无效');
|
||||||
|
if (fs.statSync(file).size < 50 * 1024 * 1024) throw new Error('DMG 体积异常偏小');
|
||||||
|
}
|
||||||
|
|
||||||
|
// tar 里的可执行位是 Linux 产物能不能启动的唯一依据,必须解包回读确认。
|
||||||
|
async function verifyLinuxTarball(file) {
|
||||||
|
const entries = readTarEntries(zlib.gunzipSync(fs.readFileSync(file)));
|
||||||
|
const names = entries.map((e) => e.name.replace(/^[^/]+\//, ''));
|
||||||
|
requireEntries(names, [PRODUCT, `${PRODUCT}.sh`, 'resources/app/main.js', 'locales/zh-CN.pak']);
|
||||||
|
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
|
||||||
|
|
||||||
|
for (const required of [PRODUCT, `${PRODUCT}.sh`, 'chrome-sandbox']) {
|
||||||
|
const entry = entries.find((e) => e.name.replace(/^[^/]+\//, '') === required);
|
||||||
|
if (!entry) throw new Error(`发布件缺少 ${required}`);
|
||||||
|
if (!(entry.mode & 0o111)) throw new Error(`${required} 缺少可执行位`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEntries(names, required) {
|
||||||
|
for (const name of required) {
|
||||||
|
if (!names.includes(name)) throw new Error(`发布件缺少 ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forbidEntries(names, patterns) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const hit = names.find((name) => pattern.test(name));
|
||||||
|
if (hit) throw new Error(`发布件混入不该发布的内容:${hit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTarEntries(buffer) {
|
||||||
|
const entries = [];
|
||||||
|
let pending = '';
|
||||||
|
for (let offset = 0; offset + 512 <= buffer.length;) {
|
||||||
|
const header = buffer.subarray(offset, offset + 512);
|
||||||
|
if (header.every((b) => b === 0)) break;
|
||||||
|
|
||||||
|
const readField = (start, length) => header
|
||||||
|
.toString('ascii', start, start + length).replace(/\0.*$/, '').trim();
|
||||||
|
const size = parseInt(readField(124, 12) || '0', 8);
|
||||||
|
const typeflag = header.toString('ascii', 156, 157);
|
||||||
|
const body = offset + 512;
|
||||||
|
const advance = 512 + Math.ceil(size / 512) * 512;
|
||||||
|
|
||||||
|
if (typeflag === 'x') {
|
||||||
|
const record = /(?:^|\n)\d+ path=([^\n]*)/.exec(buffer.toString('utf8', body, body + size));
|
||||||
|
pending = record ? record[1] : '';
|
||||||
|
} else {
|
||||||
|
entries.push({
|
||||||
|
name: pending || readField(0, 100),
|
||||||
|
mode: parseInt(readField(100, 8) || '0', 8),
|
||||||
|
size
|
||||||
|
});
|
||||||
|
pending = '';
|
||||||
|
}
|
||||||
|
offset += advance;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function packRelease(args) {
|
||||||
|
const arch = args.arch || (args.platform === 'macos' ? 'arm64' : 'x64');
|
||||||
|
const key = `${args.platform}-${arch}`;
|
||||||
|
const target = TARGETS[key];
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`不支持的目标:${key}(可选 ${Object.keys(TARGETS).join(' / ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.skipBuild) runBuild(target.build);
|
||||||
|
if (!fs.existsSync(target.source)) {
|
||||||
|
throw new Error(`构建产物不存在:${target.source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(DIST, 'release', key);
|
||||||
|
rimraf(outDir);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const outFile = path.join(outDir, target.asset);
|
||||||
|
console.log(`打包发布件 ${target.asset} ...`);
|
||||||
|
await target.pack(target.source, outFile);
|
||||||
|
|
||||||
|
console.log('校验发布件内容...');
|
||||||
|
await target.verify(outFile);
|
||||||
|
|
||||||
|
const digest = sha256(outFile);
|
||||||
|
const size = fs.statSync(outFile).size;
|
||||||
|
fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${digest} ${target.asset}\n`);
|
||||||
|
fs.writeFileSync(path.join(outDir, 'release-manifest.json'), `${JSON.stringify({
|
||||||
|
product: PRODUCT,
|
||||||
|
version: VERSION,
|
||||||
|
platform: args.platform,
|
||||||
|
arch,
|
||||||
|
commit: process.env.GITHUB_SHA || null,
|
||||||
|
files: [{ name: target.asset, size, sha256: digest }]
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log('\n发布件就绪:');
|
||||||
|
console.log(' 目标:', key);
|
||||||
|
console.log(' 文件:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`);
|
||||||
|
console.log(' 校验:', digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布任务把各平台 artifact 下载到一处,这里逐个回验哈希再汇总,
|
||||||
|
// 防止把传输中损坏或版本不一致的产物挂到 Release 上。
|
||||||
|
function verifyDownloads(inputDir) {
|
||||||
|
const root = path.resolve(inputDir);
|
||||||
|
if (!fs.existsSync(root)) throw new Error(`目录不存在:${root}`);
|
||||||
|
|
||||||
|
const manifests = walk(root)
|
||||||
|
.filter((f) => path.basename(f.rel) === 'release-manifest.json')
|
||||||
|
.sort((a, b) => (a.rel < b.rel ? -1 : 1));
|
||||||
|
if (!manifests.length) throw new Error('没有找到任何 release-manifest.json');
|
||||||
|
|
||||||
|
const outDir = path.join(DIST, 'release-upload');
|
||||||
|
rimraf(outDir);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const entry of manifests) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(entry.abs, 'utf8'));
|
||||||
|
if (manifest.version !== VERSION) {
|
||||||
|
throw new Error(`${entry.rel} 的版本 ${manifest.version} 与 package.json 的 ${VERSION} 不一致`);
|
||||||
|
}
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const asset = path.join(path.dirname(entry.abs), file.name);
|
||||||
|
if (!fs.existsSync(asset)) throw new Error(`缺少发布件 ${file.name}`);
|
||||||
|
const digest = sha256(asset);
|
||||||
|
if (digest !== file.sha256) throw new Error(`${file.name} 校验和不匹配`);
|
||||||
|
if (fs.statSync(asset).size !== file.size) throw new Error(`${file.name} 体积不匹配`);
|
||||||
|
fs.copyFileSync(asset, path.join(outDir, file.name));
|
||||||
|
rows.push(`${digest} ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort();
|
||||||
|
fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${rows.join('\n')}\n`);
|
||||||
|
console.log(`已校验 ${rows.length} 个发布件,汇总到 ${outDir}`);
|
||||||
|
for (const row of rows) console.log(' ' + row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.verify) verifyDownloads(args.verify);
|
||||||
|
else if (args.platform) await packRelease(args);
|
||||||
|
else throw new Error('缺少 --platform 或 --verify 参数');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TARGETS, parseArgs, readTarEntries };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('发布打包失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 830 B |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
// 从 icons/dist/<theme>/icon-*.png 生成 macOS 的 .icns。
|
||||||
|
// 用途:Windows 上没有 iconutil,但 icns 自 10.7 起支持直接内嵌 PNG,
|
||||||
|
// 因此容器可以手工拼出来,不必依赖 macOS 工具链。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DIST = path.join(__dirname, '..', 'dist');
|
||||||
|
|
||||||
|
// OSType -> 该槽位要求的像素尺寸。ic11/ic12/ic13/ic14 是 @2x 变体,
|
||||||
|
// 像素数等于逻辑尺寸的两倍,复用同一批 PNG 即可。
|
||||||
|
const SLOTS = [
|
||||||
|
['icp4', 16],
|
||||||
|
['icp5', 32],
|
||||||
|
['ic11', 32],
|
||||||
|
['ic12', 64],
|
||||||
|
['ic07', 128],
|
||||||
|
['ic13', 256],
|
||||||
|
['ic08', 256],
|
||||||
|
['ic14', 512],
|
||||||
|
['ic09', 512],
|
||||||
|
['ic10', 1024]
|
||||||
|
];
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
function pngSize(buf) {
|
||||||
|
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('不是 PNG 文件');
|
||||||
|
if (buf.readUInt32BE(8) !== 13 || buf.toString('latin1', 12, 16) !== 'IHDR') {
|
||||||
|
throw new Error('PNG 缺少 IHDR');
|
||||||
|
}
|
||||||
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(theme) {
|
||||||
|
const chunks = [];
|
||||||
|
for (const [osType, size] of SLOTS) {
|
||||||
|
const file = path.join(DIST, theme, `icon-${size}.png`);
|
||||||
|
const png = fs.readFileSync(file);
|
||||||
|
const dim = pngSize(png);
|
||||||
|
if (dim.width !== size || dim.height !== size) {
|
||||||
|
throw new Error(`${file} 期望 ${size}x${size},实际 ${dim.width}x${dim.height}`);
|
||||||
|
}
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header.write(osType, 0, 4, 'latin1');
|
||||||
|
header.writeUInt32BE(png.length + 8, 4);
|
||||||
|
chunks.push(header, png);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header.write('icns', 0, 4, 'latin1');
|
||||||
|
header.writeUInt32BE(body.length + 8, 4);
|
||||||
|
|
||||||
|
const out = path.join(DIST, `book-ai-${theme}.icns`);
|
||||||
|
fs.writeFileSync(out, Buffer.concat([header, body]));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const out = build(theme);
|
||||||
|
console.log(`${path.basename(out)} ${(fs.statSync(out).size / 1024).toFixed(1)} KB`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Crop the source renders to the icon tile and export PNG sizes plus .ico files."""
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ROOT = "D:/my_git/peoplelib/icons"
|
||||||
|
SIZES = [1024, 512, 256, 128, 64, 48, 32, 16]
|
||||||
|
ICO_SIZES = [256, 128, 64, 48, 32, 16]
|
||||||
|
SS = 4
|
||||||
|
|
||||||
|
SOURCES = [
|
||||||
|
("light", f"{ROOT}/ChatGPT_0CjIH8EmAj.png"),
|
||||||
|
("dark", f"{ROOT}/ChatGPT_RAga3pG7De.png"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def tile_mask(rgb):
|
||||||
|
a = rgb.astype(int)
|
||||||
|
r, g, b = a[..., 0], a[..., 1], a[..., 2]
|
||||||
|
# Anything that is not the near-white page background belongs to the artwork.
|
||||||
|
return (a.sum(2) < 748) | (np.abs(r - b) > 4) | (np.abs(g - b) > 4)
|
||||||
|
|
||||||
|
|
||||||
|
def tile_bbox(mask):
|
||||||
|
cols = mask.sum(0)
|
||||||
|
rows = mask.sum(1)
|
||||||
|
xs = np.where(cols > cols.max() * 0.35)[0]
|
||||||
|
ys = np.where(rows > rows.max() * 0.35)[0]
|
||||||
|
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
|
||||||
|
# The renders carry a drop shadow below the tile, so trust the width and
|
||||||
|
# square the crop downward from the top edge.
|
||||||
|
side = x1 - x0 + 1
|
||||||
|
return x0, x1, y0, y0 + side - 1
|
||||||
|
|
||||||
|
|
||||||
|
def corner_radius(mask, x0, x1, y0, y1):
|
||||||
|
ests = []
|
||||||
|
for d in range(10, int((y1 - y0) * 0.18)):
|
||||||
|
idx = np.where(mask[y0 + d, x0:x1 + 1])[0]
|
||||||
|
if not len(idx) or idx.min() <= 0:
|
||||||
|
continue
|
||||||
|
x = float(idx.min())
|
||||||
|
ests.append((d + x) + np.sqrt(2.0 * d * x))
|
||||||
|
return float(np.median(ests)) if ests else (x1 - x0) * 0.21
|
||||||
|
|
||||||
|
|
||||||
|
def rounded_alpha(w, h, radius, size):
|
||||||
|
n = size * SS
|
||||||
|
ys, xs = np.mgrid[0:n, 0:n].astype(np.float64)
|
||||||
|
# Map supersampled pixel centres back onto the source tile grid.
|
||||||
|
px = (xs + 0.5) / n * w
|
||||||
|
py = (ys + 0.5) / n * h
|
||||||
|
r = radius
|
||||||
|
dx = np.clip(r - px, 0, None) + np.clip(px - (w - r), 0, None)
|
||||||
|
dy = np.clip(r - py, 0, None) + np.clip(py - (h - r), 0, None)
|
||||||
|
inside = (dx * dx + dy * dy) <= r * r
|
||||||
|
cov = inside.reshape(size, SS, size, SS).mean((1, 3))
|
||||||
|
return (cov * 255).round().astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def build(name, path):
|
||||||
|
src = Image.open(path).convert("RGB")
|
||||||
|
mask = tile_mask(np.asarray(src))
|
||||||
|
x0, x1, y0, y1 = tile_bbox(mask)
|
||||||
|
radius = corner_radius(mask, x0, x1, y0, y1)
|
||||||
|
tile = src.crop((x0, y0, x1 + 1, y1 + 1))
|
||||||
|
w, h = tile.size
|
||||||
|
print(f"{name}: tile {w}x{h} radius {radius:.1f}")
|
||||||
|
|
||||||
|
outdir = f"{ROOT}/dist/{name}"
|
||||||
|
import os
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
|
||||||
|
frames = {}
|
||||||
|
for size in SIZES:
|
||||||
|
img = tile.resize((size, size), Image.LANCZOS).convert("RGBA")
|
||||||
|
img.putalpha(Image.fromarray(rounded_alpha(w, h, radius, size), "L"))
|
||||||
|
img.save(f"{outdir}/icon-{size}.png", optimize=True)
|
||||||
|
frames[size] = img
|
||||||
|
|
||||||
|
frames[1024].save(f"{ROOT}/dist/book-ai-{name}.ico", format="ICO",
|
||||||
|
sizes=[(s, s) for s in ICO_SIZES])
|
||||||
|
return frames[512]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
previews = [build(n, p) for n, p in SOURCES]
|
||||||
|
gap = 32
|
||||||
|
sheet = Image.new("RGBA", (512 * 2 + gap * 3, 512 + gap * 2), (128, 128, 128, 255))
|
||||||
|
for i, img in enumerate(previews):
|
||||||
|
sheet.paste(img, (gap + i * (512 + gap), gap), img)
|
||||||
|
sheet.save(f"{ROOT}/dist/preview.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "peoplelib",
|
"name": "peoplelib",
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library)",
|
"description": "多源开放文献、电子书与本地书库客户端",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"author": "peoplelib",
|
"author": "peoplelib",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"portable": "node build-portable.js"
|
"test": "node --test \"src/_test/*.test.js\"",
|
||||||
|
"build": "node build-portable.js",
|
||||||
|
"portable": "node build-portable.js",
|
||||||
|
"build:mac": "node build-mac.js",
|
||||||
|
"build:linux": "node build-linux.js",
|
||||||
|
"release": "node build-release.js",
|
||||||
|
"icons:icns": "node icons/tools/make-icns.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici": "^6.21.3"
|
"foliate-js": "1.0.1",
|
||||||
|
"undici": "8.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^31.0.0"
|
"dompurify": "3.4.12",
|
||||||
|
"electron": "43.2.0",
|
||||||
|
"fabric": "7.4.0",
|
||||||
|
"jspdf": "4.2.1",
|
||||||
|
"jszip": "3.10.1",
|
||||||
|
"markdown-it": "15.0.0",
|
||||||
|
"pdfjs-dist": "6.2.108",
|
||||||
|
"quill": "2.0.2",
|
||||||
|
"rcedit": "5.0.2"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.peoplelib.client",
|
"appId": "com.peoplelib.client",
|
||||||
@@ -24,10 +42,14 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"preload.js",
|
"preload.js",
|
||||||
"src/**/*"
|
"src/**/*",
|
||||||
|
"icons/dist/*.ico",
|
||||||
|
"icons/dist/dark/icon-32.png",
|
||||||
|
"icons/dist/light/icon-32.png"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": "portable"
|
"target": "portable",
|
||||||
|
"icon": "icons/dist/book-ai-dark.ico"
|
||||||
},
|
},
|
||||||
"portable": {
|
"portable": {
|
||||||
"artifactName": "PeopleLib-${version}.exe"
|
"artifactName": "PeopleLib-${version}.exe"
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
let downloadSeq = 0;
|
||||||
|
function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
|
||||||
|
const listener = (_event, data) => {
|
||||||
|
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
|
||||||
|
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
|
||||||
|
};
|
||||||
|
if (typeof onProgress === 'function') ipcRenderer.on('download:progress', listener);
|
||||||
|
return ipcRenderer
|
||||||
|
.invoke('download:file', url, suggestName, entryId, extraHeaders, meta, requestId)
|
||||||
|
.finally(() => ipcRenderer.removeListener('download:progress', listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
|
||||||
|
const requestId = `dl_${Date.now().toString(36)}_${(++downloadSeq).toString(36)}`;
|
||||||
|
return runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureReaderRect(rect) {
|
||||||
|
const value = rect && typeof rect === 'object' ? rect : {};
|
||||||
|
const area = {
|
||||||
|
x: Number(value.x),
|
||||||
|
y: Number(value.y),
|
||||||
|
width: Number(value.width),
|
||||||
|
height: Number(value.height)
|
||||||
|
};
|
||||||
|
const documentArea = document.getElementById('docArea');
|
||||||
|
const bounds = documentArea && documentArea.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
!bounds
|
||||||
|
|| !Object.values(area).every(Number.isFinite)
|
||||||
|
|| area.x < bounds.left - 1
|
||||||
|
|| area.y < bounds.top - 1
|
||||||
|
|| area.x + area.width > bounds.right + 1
|
||||||
|
|| area.y + area.height > bounds.bottom + 1
|
||||||
|
) {
|
||||||
|
return Promise.resolve({ ok: false, error: '只能截取阅读正文区域' });
|
||||||
|
}
|
||||||
|
return ipcRenderer.invoke('reader:captureRect', area);
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('api', {
|
contextBridge.exposeInMainWorld('api', {
|
||||||
sources: {
|
sources: {
|
||||||
list: () => ipcRenderer.invoke('sources:list'),
|
list: () => ipcRenderer.invoke('sources:list'),
|
||||||
@@ -11,21 +51,53 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
library: {
|
library: {
|
||||||
list: () => ipcRenderer.invoke('library:list'),
|
list: () => ipcRenderer.invoke('library:list'),
|
||||||
get: (id) => ipcRenderer.invoke('library:get', id),
|
get: (id) => ipcRenderer.invoke('library:get', id),
|
||||||
|
listShelves: () => ipcRenderer.invoke('library:listShelves'),
|
||||||
|
listTags: () => ipcRenderer.invoke('library:listTags'),
|
||||||
|
addShelf: (input) => ipcRenderer.invoke('library:addShelf', input),
|
||||||
|
updateShelf: (id, patch) => ipcRenderer.invoke('library:updateShelf', id, patch),
|
||||||
|
removeShelf: (id) => ipcRenderer.invoke('library:removeShelf', id),
|
||||||
|
addTag: (input) => ipcRenderer.invoke('library:addTag', input),
|
||||||
|
updateTag: (id, patch) => ipcRenderer.invoke('library:updateTag', id, patch),
|
||||||
|
removeTag: (id) => ipcRenderer.invoke('library:removeTag', id),
|
||||||
findBySource: (sourceId, postId) => ipcRenderer.invoke('library:findBySource', sourceId, postId),
|
findBySource: (sourceId, postId) => ipcRenderer.invoke('library:findBySource', sourceId, postId),
|
||||||
add: (item) => ipcRenderer.invoke('library:add', item),
|
add: (item) => ipcRenderer.invoke('library:add', item),
|
||||||
update: (id, patch) => ipcRenderer.invoke('library:update', id, patch),
|
update: (id, patch) => ipcRenderer.invoke('library:update', id, patch),
|
||||||
remove: (id, deleteFiles) => ipcRenderer.invoke('library:remove', id, deleteFiles),
|
updateMany: (patches) => ipcRenderer.invoke('library:updateMany', patches),
|
||||||
|
remove: (id, options) => ipcRenderer.invoke('library:remove', id, options),
|
||||||
|
removeMany: (ids, options) => ipcRenderer.invoke('library:removeMany', ids, options),
|
||||||
getDir: () => ipcRenderer.invoke('library:getDir'),
|
getDir: () => ipcRenderer.invoke('library:getDir'),
|
||||||
pickDir: () => ipcRenderer.invoke('library:pickDir'),
|
pickDir: () => ipcRenderer.invoke('library:pickDir'),
|
||||||
setDir: (dir, migrate) => ipcRenderer.invoke('library:setDir', dir, migrate),
|
setDir: (dir, migrate) => ipcRenderer.invoke('library:setDir', dir, migrate),
|
||||||
|
pickLocal: (kind) => ipcRenderer.invoke('dialog:pickLocal', kind),
|
||||||
|
importLocal: (selectionId, options) => (
|
||||||
|
ipcRenderer.invoke('library:importLocal', selectionId, options)
|
||||||
|
),
|
||||||
scan: () => ipcRenderer.invoke('library:scan'),
|
scan: () => ipcRenderer.invoke('library:scan'),
|
||||||
onChanged: (cb) => ipcRenderer.on('library:changed', () => cb())
|
onChanged: (cb) => {
|
||||||
|
const h = () => cb();
|
||||||
|
ipcRenderer.on('library:changed', h);
|
||||||
|
return () => ipcRenderer.removeListener('library:changed', h);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
get: (key, def) => ipcRenderer.invoke('settings:get', key, def),
|
get: (key, def) => ipcRenderer.invoke('settings:get', key, def),
|
||||||
set: (key, value) => ipcRenderer.invoke('settings:set', key, value)
|
set: (key, value) => ipcRenderer.invoke('settings:set', key, value)
|
||||||
},
|
},
|
||||||
downloadFile: (url, suggestName, entryId, extraHeaders, meta) => ipcRenderer.invoke('download:file', url, suggestName, entryId, extraHeaders, meta),
|
ui: {
|
||||||
|
getTheme: () => ipcRenderer.invoke('ui:getTheme'),
|
||||||
|
setTheme: (theme) => ipcRenderer.invoke('ui:setTheme', theme),
|
||||||
|
onThemeChanged: (cb) => {
|
||||||
|
const h = (_event, theme) => cb(theme);
|
||||||
|
ipcRenderer.on('ui:themeChanged', h);
|
||||||
|
return () => ipcRenderer.removeListener('ui:themeChanged', h);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
downloadFile,
|
||||||
|
downloads: {
|
||||||
|
run: runDownload,
|
||||||
|
pause: (requestId) => ipcRenderer.invoke('download:pause', requestId),
|
||||||
|
delete: (requestId) => ipcRenderer.invoke('download:delete', requestId)
|
||||||
|
},
|
||||||
zlib: {
|
zlib: {
|
||||||
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
|
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
|
||||||
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
|
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
|
||||||
@@ -40,7 +112,137 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
get: () => ipcRenderer.invoke('proxy:get'),
|
get: () => ipcRenderer.invoke('proxy:get'),
|
||||||
set: (url) => ipcRenderer.invoke('proxy:set', url)
|
set: (url) => ipcRenderer.invoke('proxy:set', url)
|
||||||
},
|
},
|
||||||
pickFile: () => ipcRenderer.invoke('dialog:pickFile'),
|
reader: {
|
||||||
|
ready: () => ipcRenderer.invoke('reader:ready'),
|
||||||
|
entryClosed: () => ipcRenderer.invoke('reader:entryClosed'),
|
||||||
|
open: (entryId, fileIndex) => ipcRenderer.invoke('reader:open', entryId, fileIndex),
|
||||||
|
openAt: (entryId, fileIndex, documentKey, locator) => (
|
||||||
|
ipcRenderer.invoke('reader:openAt', entryId, fileIndex, documentKey, locator)
|
||||||
|
),
|
||||||
|
meta: (entryId, fileIndex) => ipcRenderer.invoke('reader:meta', entryId, fileIndex),
|
||||||
|
bytes: (entryId, fileIndex) => ipcRenderer.invoke('reader:bytes', entryId, fileIndex),
|
||||||
|
rangeOpen: (entryId, fileIndex) => ipcRenderer.invoke('reader:rangeOpen', entryId, fileIndex),
|
||||||
|
rangeRead: (sessionId, begin, end) => (
|
||||||
|
ipcRenderer.invoke('reader:rangeRead', sessionId, begin, end)
|
||||||
|
),
|
||||||
|
rangeClose: (sessionId) => ipcRenderer.invoke('reader:rangeClose', sessionId),
|
||||||
|
captureRect: (rect) => captureReaderRect(rect),
|
||||||
|
openExternal: (entryId, fileIndex) => (
|
||||||
|
ipcRenderer.invoke('reader:openExternal', entryId, fileIndex)
|
||||||
|
),
|
||||||
|
getState: (entryId, documentKey) => ipcRenderer.invoke('reader:getState', entryId, documentKey),
|
||||||
|
setProgress: (entryId, documentKey, locator, percent) => (
|
||||||
|
ipcRenderer.invoke('reader:setProgress', entryId, documentKey, locator, percent)
|
||||||
|
),
|
||||||
|
addBookmark: (entryId, mark) => ipcRenderer.invoke('reader:addBookmark', entryId, mark),
|
||||||
|
removeBookmark: (entryId, markId) => ipcRenderer.invoke('reader:removeBookmark', entryId, markId),
|
||||||
|
addNote: (entryId, note) => ipcRenderer.invoke('reader:addNote', entryId, note),
|
||||||
|
addStandaloneNote: (note) => ipcRenderer.invoke('reader:addStandaloneNote', note),
|
||||||
|
updateNote: (entryId, noteId, patch) => ipcRenderer.invoke('reader:updateNote', entryId, noteId, patch),
|
||||||
|
removeNote: (entryId, noteId) => ipcRenderer.invoke('reader:removeNote', entryId, noteId),
|
||||||
|
listNotes: (filters) => ipcRenderer.invoke('reader:listNotes', filters),
|
||||||
|
getNoteCounts: () => ipcRenderer.invoke('reader:getNoteCounts'),
|
||||||
|
getAnnotationCounts: () => ipcRenderer.invoke('reader:getAnnotationCounts'),
|
||||||
|
orphanReport: () => ipcRenderer.invoke('reader:orphanReport'),
|
||||||
|
purgeOrphans: (scope) => ipcRenderer.invoke('reader:purgeOrphans', scope),
|
||||||
|
listCollections: () => ipcRenderer.invoke('reader:listCollections'),
|
||||||
|
addCollection: (input) => ipcRenderer.invoke('reader:addCollection', input),
|
||||||
|
updateCollection: (id, patch) => ipcRenderer.invoke('reader:updateCollection', id, patch),
|
||||||
|
removeCollection: (id) => ipcRenderer.invoke('reader:removeCollection', id),
|
||||||
|
pickNotePdf: () => ipcRenderer.invoke('reader:pickNotePdf'),
|
||||||
|
notePdfBytes: (ref) => ipcRenderer.invoke('reader:notePdfBytes', ref),
|
||||||
|
saveNotePdf: (bytes, suggestedName) => (
|
||||||
|
ipcRenderer.invoke('reader:saveNotePdf', bytes, suggestedName)
|
||||||
|
),
|
||||||
|
getAnnotations: (entryId, fileIndex) => ipcRenderer.invoke('reader:getAnnotations', entryId, fileIndex),
|
||||||
|
setAnnotationPage: (entryId, fileIndex, page, data) => ipcRenderer.invoke('reader:setAnnotationPage', entryId, fileIndex, page, data),
|
||||||
|
onOpenEntry: (cb) => {
|
||||||
|
const h = (_e, data) => cb(data);
|
||||||
|
ipcRenderer.on('reader:openEntry', h);
|
||||||
|
return () => ipcRenderer.removeListener('reader:openEntry', h);
|
||||||
|
},
|
||||||
|
onCloseEntry: (cb) => {
|
||||||
|
const h = (_e, entryId) => cb(entryId);
|
||||||
|
ipcRenderer.on('reader:closeEntry', h);
|
||||||
|
return () => ipcRenderer.removeListener('reader:closeEntry', h);
|
||||||
|
},
|
||||||
|
onPurgeEntry: (cb) => {
|
||||||
|
const h = async (_e, data) => {
|
||||||
|
try { await cb(data); } finally {
|
||||||
|
if (data && data.requestId) ipcRenderer.send('reader:purgeReady', data.requestId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ipcRenderer.on('reader:purgeEntry', h);
|
||||||
|
return () => ipcRenderer.removeListener('reader:purgeEntry', h);
|
||||||
|
},
|
||||||
|
onPrepareClose: (cb) => {
|
||||||
|
const h = async () => {
|
||||||
|
try { await cb(); } finally { ipcRenderer.send('reader:shutdownReady'); }
|
||||||
|
};
|
||||||
|
ipcRenderer.on('reader:prepareClose', h);
|
||||||
|
return () => ipcRenderer.removeListener('reader:prepareClose', h);
|
||||||
|
},
|
||||||
|
onNotesChanged: (cb) => {
|
||||||
|
const h = (_e, data) => cb(data);
|
||||||
|
ipcRenderer.on('reader:notesChanged', h);
|
||||||
|
return () => ipcRenderer.removeListener('reader:notesChanged', h);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
openWindow: (entryId, noteId) => ipcRenderer.invoke('notes:openWindow', entryId, noteId),
|
||||||
|
getOne: (entryId, noteId) => ipcRenderer.invoke('notes:getOne', entryId, noteId),
|
||||||
|
openWindows: () => ipcRenderer.invoke('notes:openWindows'),
|
||||||
|
tabsChanged: (tabs) => ipcRenderer.invoke('notes:tabsChanged', tabs),
|
||||||
|
shutdownReady: () => ipcRenderer.invoke('notes:shutdownReady'),
|
||||||
|
cancelClose: () => ipcRenderer.invoke('notes:cancelClose'),
|
||||||
|
onWindowsChanged: (cb) => {
|
||||||
|
const h = (_e, data) => cb(data);
|
||||||
|
ipcRenderer.on('notes:windowsChanged', h);
|
||||||
|
return () => ipcRenderer.removeListener('notes:windowsChanged', h);
|
||||||
|
},
|
||||||
|
onOpenTab: (cb) => {
|
||||||
|
const h = (_e, data) => cb(data);
|
||||||
|
ipcRenderer.on('notes:openTab', h);
|
||||||
|
return () => ipcRenderer.removeListener('notes:openTab', h);
|
||||||
|
},
|
||||||
|
onCloseTab: (cb) => {
|
||||||
|
const h = (_e, data) => cb(data);
|
||||||
|
ipcRenderer.on('notes:closeTab', h);
|
||||||
|
return () => ipcRenderer.removeListener('notes:closeTab', h);
|
||||||
|
},
|
||||||
|
onPrepareClose: (cb) => {
|
||||||
|
const h = () => cb();
|
||||||
|
ipcRenderer.on('notes:prepareClose', h);
|
||||||
|
return () => ipcRenderer.removeListener('notes:prepareClose', h);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
status: () => ipcRenderer.invoke('ai:status'),
|
||||||
|
save: (cfg) => ipcRenderer.invoke('ai:save', cfg),
|
||||||
|
clear: () => ipcRenderer.invoke('ai:clear'),
|
||||||
|
run: (payload) => ipcRenderer.invoke('ai:run', payload),
|
||||||
|
cancel: (runId) => ipcRenderer.invoke('ai:cancel', runId),
|
||||||
|
sessions: {
|
||||||
|
list: (filters) => ipcRenderer.invoke('ai:sessionList', filters),
|
||||||
|
create: (input) => ipcRenderer.invoke('ai:sessionCreate', input),
|
||||||
|
rename: (sessionId, title) => ipcRenderer.invoke('ai:sessionRename', sessionId, title),
|
||||||
|
setPinned: (sessionId, pinned) => ipcRenderer.invoke('ai:sessionPin', sessionId, pinned),
|
||||||
|
remove: (sessionId) => ipcRenderer.invoke('ai:sessionRemove', sessionId),
|
||||||
|
clear: (sessionId) => ipcRenderer.invoke('ai:sessionClear', sessionId),
|
||||||
|
messages: (sessionId, options) => ipcRenderer.invoke('ai:sessionMessages', sessionId, options)
|
||||||
|
},
|
||||||
|
onChanged: (cb) => {
|
||||||
|
const h = (_e, data) => cb(data);
|
||||||
|
ipcRenderer.on('ai:changed', h);
|
||||||
|
return () => ipcRenderer.removeListener('ai:changed', h);
|
||||||
|
},
|
||||||
|
// 返回取消订阅函数:阅读器窗口关闭时要能解绑,否则监听器会越积越多
|
||||||
|
onDelta: (cb) => {
|
||||||
|
const h = (_e, data) => cb(data);
|
||||||
|
ipcRenderer.on('ai:delta', h);
|
||||||
|
return () => ipcRenderer.removeListener('ai:delta', h);
|
||||||
|
}
|
||||||
|
},
|
||||||
openPath: (p) => ipcRenderer.invoke('shell:openPath', p),
|
openPath: (p) => ipcRenderer.invoke('shell:openPath', p),
|
||||||
showItem: (p) => ipcRenderer.invoke('shell:showItem', p),
|
showItem: (p) => ipcRenderer.invoke('shell:showItem', p),
|
||||||
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
|
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>页面不存在 - PeopleLib</title>
|
||||||
|
<meta name="description" content="请求的页面不存在。返回 PeopleLib 首页,或前往功能、隐私、下载与常见问题页面。">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<img class="brand-light" src="/assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="/assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">首页</a></li>
|
||||||
|
<li><a href="/features.html">功能</a></li>
|
||||||
|
<li><a href="/privacy.html">隐私</a></li>
|
||||||
|
<li><a href="/download.html">下载</a></li>
|
||||||
|
<li><a href="/faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<section class="notfound">
|
||||||
|
<div class="wrap">
|
||||||
|
<span class="eyebrow">404</span>
|
||||||
|
<h1>这个页面不存在</h1>
|
||||||
|
<p>地址可能拼错了,或者页面已经移动。</p>
|
||||||
|
<p class="btn-row btn-row-center">
|
||||||
|
<a class="btn btn-primary" href="/">回到首页</a>
|
||||||
|
<a class="btn btn-secondary" href="/download.html">下载页</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="legal">
|
||||||
|
PeopleLib,开放获取文献与图书的桌面客户端。MIT 许可。
|
||||||
|
<a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
reader.mesalogo.com
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# 站点部署说明
|
||||||
|
|
||||||
|
`reader.mesalogo.com` 的 GitHub Pages 部署步骤。站点是纯静态的,没有构建步骤、没有 npm 依赖、没有 CDN 外链。
|
||||||
|
|
||||||
|
本文里凡是标注「查证」的,出处是 GitHub 官方文档(已抓取正文核对);标注「推断」的是我根据文档与本仓库现状的判断,需要你实际操作时确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、文件清单
|
||||||
|
|
||||||
|
站点全部文件在 `site/`,目录本身就是站点根。
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `site/index.html` | 首页。价值主张、核心能力、隐私要点、真实截图、格式对照表 |
|
||||||
|
| `site/features.html` | 功能页。按真实功能清单展开,末尾有「不包含的功能」一节 |
|
||||||
|
| `site/privacy.html` | 隐私与安全。本地优先、自带密钥、确认后外发、进程边界、不绕 DRM、联网范围表 |
|
||||||
|
| `site/download.html` | 下载与安装。Windows 免安装步骤、macOS DMG 与首次打开的处理、首启建议 |
|
||||||
|
| `site/faq.html` | 常见问题。费用、数据位置、格式、macOS 拦截、离线、更新、超大 PDF 等 |
|
||||||
|
| `site/404.html` | 自定义 404。用根绝对路径引用资源(见下文注意事项) |
|
||||||
|
| `site/assets/style.css` | 唯一样式表。深浅两套配色随 `prefers-color-scheme` 切换 |
|
||||||
|
| `site/assets/shot-*.jpg` | 产品截图,由 `docs/screenshots/` 原图缩放转码而来 |
|
||||||
|
| `site/assets/icon-{light,dark}-{32,256,512}.png` | favicon 与品牌标记,从 `icons/dist/` 复制 |
|
||||||
|
| `site/assets/og-cover.jpg` | Open Graph 分享封面,1200x630 |
|
||||||
|
| `site/assets/build-assets.ps1` | 一次性资产生成脚本。换截图时手动重跑,站点本身不依赖它。只写 `site/assets/`,只读 `docs/screenshots/` 与 `icons/dist/` |
|
||||||
|
| `site/CNAME` | 自定义域名声明,内容是单行 `reader.mesalogo.com` |
|
||||||
|
| `site/.nojekyll` | 空文件,跳过 Jekyll 处理 |
|
||||||
|
| `site/robots.txt` | 允许全部抓取,指向 sitemap |
|
||||||
|
| `site/sitemap.xml` | 五个页面的站点地图 |
|
||||||
|
|
||||||
|
没有 JavaScript。折叠式常见问题用原生 `<details>` 实现,不需要脚本。
|
||||||
|
|
||||||
|
### 已做的校验
|
||||||
|
|
||||||
|
- 六个页面在移动宽度(390px)下 `scrollWidth == clientWidth`,无横向溢出。
|
||||||
|
- 全部内部链接与资源路径存在,全部页内锚点有效。
|
||||||
|
- 页面只引用本地资源。外部地址只出现在 `<a href>`(指向 github.com)与 `<link rel="canonical">`。
|
||||||
|
- 浏览器控制台无报错、无警告。
|
||||||
|
- 深浅两套配色的正文、次要文字、按钮、链接、页脚、表头对比度全部达到 WCAG AA,最低一项 4.75:1。
|
||||||
|
- 每页一个 `h1`,`lang="zh-CN"`,全部 `<img>` 有 `alt`,装饰性图标用空 `alt`。
|
||||||
|
- 全部文件 UTF-8 无 BOM,无 emoji,无破折号。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、部署方案
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
|
||||||
|
本仓库有两个远端,用途不同:
|
||||||
|
|
||||||
|
- `github`(`git@github.com:lofyer/peoplelib.git`,公开):源码与 CI,历史与本地无共同祖先。
|
||||||
|
- `origin`(私有 Gitea):完整源码,日常开发推这里。
|
||||||
|
|
||||||
|
GitHub Pages 只能托管在公开仓库或付费计划的私有仓库上。站点要走公开仓库,所以核心问题是:**怎么让站点有自己独立的一条历史,改站点不牵动源码分支。**
|
||||||
|
|
||||||
|
顺带提醒一句(查证):Pages 站点在互联网上始终是公开的,即使仓库是私有的。所以不要指望靠仓库权限藏住站点内容。
|
||||||
|
|
||||||
|
### 三种可选方式
|
||||||
|
|
||||||
|
GitHub 文档明确的发布源只有两类(查证):从某个分支发布,源目录只能是该分支的根 `/` 或 `/docs`;或者用自定义 GitHub Actions 工作流发布。
|
||||||
|
|
||||||
|
| 方式 | 是否可行 | 评价 |
|
||||||
|
|---|---|---|
|
||||||
|
| 公开仓库 `main` 分支的 `/docs` | 可行 | 但公开仓库的 `docs/screenshots/` 已被 README 引用,站点文件混进同一目录后,`docs/` 既是文档目录又是站点根,语义混乱。而且 README 与站点共用一次提交,改站点会污染 README 的历史 |
|
||||||
|
| 公开仓库独立 `gh-pages` 分支,根目录就是站点 | **推荐** | 分支里只有站点文件,改站点不牵动 `main` 的源码历史,两条线互不干扰 |
|
||||||
|
| GitHub Actions 工作流 | 不推荐 | 站点零构建,Actions 唯一的价值是自动化,收益抵不上多出来的运行时依赖与调试面。`main` 上的工作流只管应用的构建与发布 |
|
||||||
|
|
||||||
|
### 结论:公开仓库的 `gh-pages` orphan 分支,源目录 `/`
|
||||||
|
|
||||||
|
三个问题的答案:
|
||||||
|
|
||||||
|
1. **部署方式**:从公开仓库的 `gh-pages` 分支发布,源目录选根 `/`。不用 `/docs`,不用 Actions。
|
||||||
|
2. **CNAME 位置**:放在**发布分支的根目录**。因为 `site/` 的内容会成为 `gh-pages` 的根,所以 `site/CNAME` 推上去之后自然就在 `gh-pages` 根上,位置正确,不需要移动。(查证:从分支发布时,在 Settings 里保存自定义域名会自动在源分支根目录提交一个 `CNAME` 文件;反过来,你自己先放好这个文件也一样生效。用 Actions 发布则不会创建 `CNAME`,已有的也会被忽略。)
|
||||||
|
3. **`site/` 目录名会不会冲突**:不冲突,但**前提是用上面这个方案**。Pages 认不了名为 `site` 的源目录,它只认分支根或 `/docs`。而在本方案里 `site/` 只是私有仓库里的源目录,推送时把它的**内容**摊到 `gh-pages` 的根,Pages 看到的是根目录,所以目录叫什么都无所谓。**不需要改名,也不需要动仓库里其他任何地方。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、发布操作
|
||||||
|
|
||||||
|
### 3.1 首次发布
|
||||||
|
|
||||||
|
用一个临时 worktree 挂 orphan 分支,避免污染主工作区。以下命令在 PowerShell 下逐条执行,`$repo` 换成你的实际路径。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$repo = "D:\my_git\peoplelib"
|
||||||
|
$wt = "D:\my_git\peoplelib-pages" # 临时工作树,放在仓库外面
|
||||||
|
|
||||||
|
cd $repo
|
||||||
|
|
||||||
|
# 1. 建一个挂着 orphan 分支的工作树。orphan 意味着无父提交,天然与源码历史无共同祖先
|
||||||
|
git worktree add --orphan -b gh-pages $wt
|
||||||
|
|
||||||
|
# 2. 把站点内容摊到工作树根目录。注意是 site\ 的内容,不是 site 这个目录本身
|
||||||
|
Copy-Item -Path "$repo\site\*" -Destination $wt -Recurse -Force
|
||||||
|
Copy-Item -Path "$repo\site\.nojekyll" -Destination $wt -Force # 点开头的文件通配符可能漏掉,单独补一次
|
||||||
|
|
||||||
|
# 3. 确认工作树里只有站点文件,没有任何源码
|
||||||
|
cd $wt
|
||||||
|
git status --porcelain
|
||||||
|
Get-ChildItem -Force | Select-Object Name
|
||||||
|
|
||||||
|
# 4. 提交
|
||||||
|
git add -A
|
||||||
|
git commit -m "站点:发布 reader.mesalogo.com 首版"
|
||||||
|
|
||||||
|
# 5. 推到公开远端
|
||||||
|
git push github gh-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
`git worktree add --orphan` 需要 Git 2.42 或更高。本机是 2.55.0,可用。
|
||||||
|
|
||||||
|
发布脚本里不要 `git add` 那个 `assets/build-assets.ps1`,其实带上也无妨,它只是个纯文本工具脚本,不含源码逻辑,留着方便日后换图。你若不想让它进公开仓库,第 3 步之后删掉即可:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Remove-Item "$wt\assets\build-assets.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 后续更新
|
||||||
|
|
||||||
|
工作树保留着的话,更新只要重复复制加提交:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$repo = "D:\my_git\peoplelib"
|
||||||
|
$wt = "D:\my_git\peoplelib-pages"
|
||||||
|
|
||||||
|
cd $wt
|
||||||
|
# 先清干净,避免删掉的文件残留在分支上(保留 .git)
|
||||||
|
Get-ChildItem -Force | Where-Object { $_.Name -ne '.git' } | Remove-Item -Recurse -Force
|
||||||
|
|
||||||
|
Copy-Item -Path "$repo\site\*" -Destination $wt -Recurse -Force
|
||||||
|
Copy-Item -Path "$repo\site\.nojekyll" -Destination $wt -Force
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "站点:更新下载说明"
|
||||||
|
git push github gh-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
不再需要工作树时清理:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd $repo
|
||||||
|
git worktree remove ..\peoplelib-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 需要你决定的两件事
|
||||||
|
|
||||||
|
这两件都在 `site/` 之外,我没有动,交给你:
|
||||||
|
|
||||||
|
1. **`.gitignore`**:`site/` 目前**没有**被忽略,`git status` 能看到它。如果你希望站点源文件跟着私有仓库一起走版本管理(推荐,这样才有历史可查),就什么都不用改。如果你不想让它进私有仓库,自己在 `.gitignore` 里加 `/site/`,注意要带前导斜杠,否则会连带匹配其他层级的同名目录,这和仓库里 `/dist/` 那条规则是同一个坑。
|
||||||
|
|
||||||
|
2. **不要加 GitHub Actions workflow**。上面已经论证过不推荐。如果你后来改主意要走 Actions,记住一条(查证):用 Actions 发布时 `CNAME` 文件不会被创建,已存在的也会被忽略且不是必需的,域名完全由 Settings 里的配置决定,那时 `site/CNAME` 就成了无用文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、GitHub 仓库设置
|
||||||
|
|
||||||
|
在 `https://github.com/lofyer/peoplelib` 上操作。
|
||||||
|
|
||||||
|
1. 进 **Settings**,左侧 **Code and automation** 分组里点 **Pages**。
|
||||||
|
2. **Build and deployment** 的 Source 选 **Deploy from a branch**。
|
||||||
|
3. Branch 选 **`gh-pages`**,Folder 选 **`/ (root)`**,点 **Save**。
|
||||||
|
4. **Custom domain** 填 `reader.mesalogo.com`,点 **Save**。
|
||||||
|
|
||||||
|
第 4 步之后 GitHub 会自动跑一次 DNS 检查。因为 `site/CNAME` 已经在分支根上且内容正确,这一步通常不会再产生额外提交;如果 GitHub 仍然自己提交了一次 `CNAME`,那是正常行为(查证:从分支发布时保存自定义域名会在源分支根目录提交 `CNAME`),下次更新前先 `git pull github gh-pages` 同步一下即可。
|
||||||
|
|
||||||
|
### 关于域名验证
|
||||||
|
|
||||||
|
GitHub 文档建议(查证):**先验证自定义域名,再把它加到仓库里**,以提升安全性、避免域名被抢占。验证入口在个人或组织的 **Settings → Pages → Add a domain**,它会要求你加一条 `_github-pages-challenge-<user>.mesalogo.com` 的 TXT 记录。
|
||||||
|
|
||||||
|
这一步不是必须的,但如果你以后停用了 Pages 而 DNS 记录还留着,未验证的域名可能被别人拿去托管他们自己的站点。建议做。(查证:文档明确说明未验证且站点停用时存在被接管的风险。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、DNS 配置
|
||||||
|
|
||||||
|
`reader.mesalogo.com` 是**子域名**,不是 apex 域名。这个区别决定了记录类型。
|
||||||
|
|
||||||
|
### 需要加的记录
|
||||||
|
|
||||||
|
一条,就一条(查证):
|
||||||
|
|
||||||
|
| 类型 | 名称 | 值 | TTL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CNAME` | `reader`(即 `reader.mesalogo.com`) | `lofyer.github.io` | 自动 / 默认 |
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 值是 `lofyer.github.io`,**不带仓库名**。文档原文是 CNAME 记录应始终指向 `<user>.github.io` 或 `<organization>.github.io`,排除仓库名。(查证)
|
||||||
|
- 值不带 `https://`,不带结尾斜杠。有些 DNS 面板要求结尾带点写成 `lofyer.github.io.`,按面板的格式来。
|
||||||
|
- **不要**加 A 记录或 AAAA 记录。那四个 `185.199.10x.153` 与对应的 IPv6 地址是给 **apex 域名**(`example.com` 这种)用的。子域名只需要 CNAME。(查证)
|
||||||
|
- **不要**用通配符记录如 `*.mesalogo.com`。文档强烈反对,因为即使验证了域名,通配符覆盖下的更深层子域名仍可能被接管。(查证)
|
||||||
|
|
||||||
|
`site/CNAME` 文件与 DNS 里的 CNAME 记录是两件不同的东西,名字撞车而已:文件告诉 GitHub「这个站点用哪个域名」,DNS 记录告诉全世界「这个域名指向哪台服务器」。两边都要配。
|
||||||
|
|
||||||
|
### 当前状态
|
||||||
|
|
||||||
|
我查过(`Resolve-DnsName`):
|
||||||
|
|
||||||
|
- `reader.mesalogo.com` 目前**不存在**,返回「DNS 名称不存在」。所以是全新添加,不会覆盖已有记录。
|
||||||
|
- `mesalogo.com` 的权威 NS 是 `serena.ns.cloudflare.com` 与 `aarav.ns.cloudflare.com`,也就是**域名托管在 Cloudflare**。
|
||||||
|
|
||||||
|
### Cloudflare 特有注意事项(推断,非 GitHub 文档内容)
|
||||||
|
|
||||||
|
在 Cloudflare 面板加这条 CNAME 时,右侧有个 Proxy status 开关:
|
||||||
|
|
||||||
|
- 建议先设成 **DNS only**(灰色云朵)。橙色云朵代表 Cloudflare 代理,此时 Cloudflare 会自己终止 TLS,GitHub 那边的 DNS 检查可能拿不到期望的应答,导致证书签发卡住或反复失败。
|
||||||
|
- 等 GitHub 侧证书签发成功、`https://reader.mesalogo.com` 能正常打开之后,如果你确实想用 Cloudflare 的 CDN,再切成橙色云朵,并把 SSL/TLS 模式设为 **Full (strict)**。不要用 Flexible,那会造成 Cloudflare 到 GitHub 之间走明文。
|
||||||
|
- Cloudflare 默认 TTL 是 Auto,不用改。
|
||||||
|
|
||||||
|
这一节是我的操作建议,GitHub 文档不涉及具体 DNS 服务商。请以实际结果为准,卡住了先把云朵切灰。
|
||||||
|
|
||||||
|
### 验证 DNS 是否生效
|
||||||
|
|
||||||
|
Windows 没有 `dig`(查证:文档明确提到这一点并推荐 `Resolve-DnsName`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Resolve-DnsName reader.mesalogo.com -Type CNAME
|
||||||
|
```
|
||||||
|
|
||||||
|
期望看到 `NameHost` 是 `lofyer.github.io`。
|
||||||
|
|
||||||
|
DNS 变更最多需要 24 小时传播(查证)。多数情况几分钟就好,但如果刚改完查不到,先等,别急着反复改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、HTTPS 证书
|
||||||
|
|
||||||
|
顺序很重要:**先 DNS 生效,再等证书,最后勾 Enforce HTTPS。**
|
||||||
|
|
||||||
|
流程(查证):
|
||||||
|
|
||||||
|
1. 你在 Settings → Pages 里保存或修改自定义域名后,GitHub 自动开始一次 DNS 检查。
|
||||||
|
2. 检查通过后,GitHub 排队向 Let's Encrypt 申请 TLS 证书,拿到后自动部署到负责 Pages TLS 终止的服务器上。
|
||||||
|
3. 全流程成功后,Settings → Pages 的自定义域名旁边会出现一个**对勾**。
|
||||||
|
4. 这时再勾选 **Enforce HTTPS**,所有 HTTP 请求会被透明重定向到 HTTPS。
|
||||||
|
|
||||||
|
排障(查证):如果点了 Save 之后**几分钟**还没完成,出现「Certificate not yet created」,就点域名旁边的 **Remove**,重新输入域名再 **Save**,这会取消并重启签发流程。
|
||||||
|
|
||||||
|
关于 Enforce HTTPS 的勾选时机:文档说所有 Pages 站点包括正确配置了自定义域名的站点都支持 HTTPS 与 HTTPS 强制。**推断**:在证书还没签发出来(域名旁边没有对勾)时,这个复选框通常是灰的点不动,或者勾上会导致站点短时间打不开。所以按上面的顺序走,看到对勾再勾它。
|
||||||
|
|
||||||
|
混合内容(查证):如果页面里有 `http://` 开头的图片、CSS 或 JS,站点会被判定为混合内容。本站点不存在这个问题,所有资源都是相对路径引用的本地文件,已经校验过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、注意事项与已知坑
|
||||||
|
|
||||||
|
### 404 页面用的是根绝对路径
|
||||||
|
|
||||||
|
`site/404.html` 里的资源引用写成 `/assets/style.css` 这样的根绝对路径,因为 GitHub 会拿这个页面响应任意深度的错误路径,相对路径在 `/a/b/c` 这种地址下会解析错。
|
||||||
|
|
||||||
|
**代价**:在自定义域名生效之前,站点临时地址是 `https://lofyer.github.io/peoplelib/`,此时 404 页面的样式与图标会加载失败(它去找 `lofyer.github.io/assets/...` 而不是 `/peoplelib/assets/...`)。域名生效后站点在根路径上,一切正常。其他五个页面全部用相对路径,两种地址下都正常。
|
||||||
|
|
||||||
|
如果你想在临时地址下也让 404 页面完整,把该文件里的 `/assets/` 与 `/features.html` 等改成相对路径,代价是深层路径下样式丢失。二者不能同时满足,建议维持现状,等域名生效。
|
||||||
|
|
||||||
|
### `.nojekyll`
|
||||||
|
|
||||||
|
空文件,作用是跳过 Jekyll 处理。当前站点里没有下划线开头的文件或目录,严格说不加也能正常发布;加上是防御性的,避免以后新增 `_something` 之类的路径时被 Jekyll 悄悄吞掉。(Jekyll 忽略下划线前缀路径这一行为属于既有共识,本次未逐字查证 GitHub 文档;`.nojekyll` 本身在发布源文档里被提到是外部 CI 部署的常见做法。)
|
||||||
|
|
||||||
|
复制文件时留意:PowerShell 的 `Copy-Item site\*` 通配符可能不匹配点开头的文件,所以上面的命令里单独补了一次 `.nojekyll`。这个坑很容易漏,漏了不会报错,只是文件没过去。
|
||||||
|
|
||||||
|
### 版本号会过期
|
||||||
|
|
||||||
|
页脚、首页与下载页都写了 **2.0.0**,取自 `package.json` 的 `version` 字段。发新版时记得改,一共出现在这几处:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Select-String -Path D:\my_git\peoplelib\site\*.html -Pattern '1\.3\.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 站点不引用 `docs/screenshots/` 原图
|
||||||
|
|
||||||
|
`site/assets/shot-*.jpg` 是压缩转码后的副本,和 `docs/screenshots/` 的原始 PNG 相互独立。换截图时替换 `docs/screenshots/` 里的原图,然后重跑:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File D:\my_git\peoplelib\site\assets\build-assets.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本只写 `site/assets/`,只读 `docs/screenshots/` 与 `icons/dist/`。
|
||||||
|
|
||||||
|
### 仓库根目录的诊断图没有被使用
|
||||||
|
|
||||||
|
`font-missing-diagnostic.png`、`probe*.json` 之类的诊断产物一个都没进站点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、需要你补的资产
|
||||||
|
|
||||||
|
### 现在用的是真实截图,不是占位图
|
||||||
|
|
||||||
|
站点已经用上了 `docs/screenshots/` 里的六张真实截图,缩放到 1600px 宽、JPEG 质量 88:
|
||||||
|
|
||||||
|
| 站点文件 | 来源 | 用在哪 |
|
||||||
|
|---|---|---|
|
||||||
|
| `shot-library.jpg` | `PeopleLib_bAecy2Izab.png` | 首页主视觉、OG 封面 |
|
||||||
|
| `shot-search.jpg` | `PeopleLib_34BtvLsqDE.png` | 首页画廊、功能页检索节 |
|
||||||
|
| `shot-reader.jpg` | `PeopleLib_ScQMUA2D24.png` | 首页画廊、功能页阅读节 |
|
||||||
|
| `shot-ai.jpg` | `PeopleLib_2N6zVpCFBA.png` | 首页画廊、功能页 AI 节 |
|
||||||
|
| `shot-ai-confirm.jpg` | `ai-send-confirmation.png` | 首页画廊、隐私页确认框一节 |
|
||||||
|
| `shot-annotations.jpg` | `pdf-annotations.png` | 功能页批注节 |
|
||||||
|
|
||||||
|
所以站点**不缺图也能直接上线**。下面是可以让它更好的补充项,都是可选的。
|
||||||
|
|
||||||
|
### 建议补拍的截图
|
||||||
|
|
||||||
|
| 优先级 | 内容 | 用在哪 | 建议尺寸 | 说明 |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| 高 | **AI 设置页**,展示接口地址、协议选择与 Key 输入框 | 隐私页「AI 密钥」一节 | 宽 ≥ 1600px,16:10 左右 | 目前这一节全是文字。给出真实界面能显著提升「自带密钥」这个卖点的说服力。**截图前务必清空或涂掉真实 Key** |
|
||||||
|
| 高 | **画布笔记**,最好是以 PDF 页面作底版的那种 | 功能页「笔记与批注」节 | 宽 ≥ 1600px | 画布笔记是差异化功能,现在只有文字描述 |
|
||||||
|
| 中 | **书架与标签的批量整理**,多选状态下的操作栏 | 功能页「本地书库」节 | 宽 ≥ 1600px | 那一节现在是定义列表,没有配图 |
|
||||||
|
| 中 | **macOS 首次打开的系统提示框**,以及右键菜单里的「打开」 | 下载页 macOS 节、常见问题 | 宽 800 至 1200px 即可 | 这是最容易让用户误判为病毒的环节,配图比文字管用得多。需要在 macOS 上截 |
|
||||||
|
| 中 | **EPUB 阅读界面**,带目录侧栏 | 功能页 EPUB 卡片 | 宽 ≥ 1600px | 现有截图全是 PDF,看不出 EPUB 的重排效果 |
|
||||||
|
| 低 | **浅色主题下的书库界面** | 首页主视觉的浅色替换 | 宽 ≥ 1600px | 现有截图都是深色界面,浅色站点配色下略显突兀。若要做,需要给 `<picture>` 加 `prefers-color-scheme` 分支,改动量不大但需要我再写一次 |
|
||||||
|
| 低 | **代理设置界面** | 功能页「代理与镜像容错」节 | 宽 ≥ 1600px | 截图前遮掉真实代理地址 |
|
||||||
|
|
||||||
|
补图流程:把新 PNG 放进 `docs/screenshots/`,在 `site/assets/build-assets.ps1` 的 `$map` 数组里加一行映射,重跑脚本,然后在对应 HTML 里插 `<figure class="shot">`。
|
||||||
|
|
||||||
|
### 图标
|
||||||
|
|
||||||
|
已经复用 `icons/dist/` 的应用图标,浅色深色两套按 `prefers-color-scheme` 切换,不需要新画。
|
||||||
|
|
||||||
|
唯一缺的是 **`favicon.ico`**。当前只提供 32px PNG favicon,现代浏览器都认,但少数老浏览器和某些抓取器会去根目录硬找 `/favicon.ico`。要补的话把 `icons/dist/book-ai-light.ico` 复制成 `site/favicon.ico` 即可,属于可选项。
|
||||||
|
|
||||||
|
### Open Graph 图
|
||||||
|
|
||||||
|
`site/assets/og-cover.jpg` 已生成,1200x630,81 KB,内容是深色底加应用图标、产品名、两行说明与域名,右侧放书库截图。由 `build-assets.ps1` 用 GDI+ 绘制,字体是「Microsoft YaHei」。
|
||||||
|
|
||||||
|
**推断**:如果这张图要在正式场合露脸,建议你用设计工具重做一版。脚本生成的版式够用但排版粗糙,尤其是文字与截图之间的留白比例。替换时直接覆盖 `site/assets/og-cover.jpg`,保持 1200x630 与文件名不变,HTML 里的 `og:image` 尺寸声明就不用动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、上线后的自查清单
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# DNS
|
||||||
|
Resolve-DnsName reader.mesalogo.com -Type CNAME
|
||||||
|
|
||||||
|
# 首页可达且是 200
|
||||||
|
curl.exe -sSI https://reader.mesalogo.com/ | Select-Object -First 1
|
||||||
|
|
||||||
|
# HTTP 是否重定向到 HTTPS(勾了 Enforce HTTPS 之后应该是 301)
|
||||||
|
curl.exe -sSI http://reader.mesalogo.com/ | Select-Object -First 3
|
||||||
|
|
||||||
|
# 404 页面生效(应返回 404 且内容是中文自定义页)
|
||||||
|
curl.exe -sS -o NUL -w "%{http_code}`n" https://reader.mesalogo.com/nonexistent
|
||||||
|
|
||||||
|
# 五个页面全部可达
|
||||||
|
'', 'features.html', 'privacy.html', 'download.html', 'faq.html' | ForEach-Object {
|
||||||
|
$u = "https://reader.mesalogo.com/$_"
|
||||||
|
"{0} {1}" -f (curl.exe -sS -o NUL -w "%{http_code}" $u), $u
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器里再手工看四件事:
|
||||||
|
|
||||||
|
1. 切换系统深浅色主题,页面配色跟着变。
|
||||||
|
2. 手机上打开,导航与卡片不溢出。
|
||||||
|
3. 把首页链接贴进任意支持 Open Graph 预览的地方,标题、描述与封面图正常显示。
|
||||||
|
4. 键盘按 Tab,焦点框可见,第一次 Tab 出现「跳到主要内容」。
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 一次性资产生成脚本:把 docs/screenshots 与 icons/dist 的原图缩放并转码到 site/assets。
|
||||||
|
# 站点本身零构建,这个脚本只在替换截图时手动重跑。
|
||||||
|
param(
|
||||||
|
[string]$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
)
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$outDir = $PSScriptRoot
|
||||||
|
$srcShots = Join-Path $Repo 'docs\screenshots'
|
||||||
|
$srcIcons = Join-Path $Repo 'icons\dist'
|
||||||
|
|
||||||
|
function Save-Jpeg {
|
||||||
|
param([System.Drawing.Bitmap]$Bitmap, [string]$Path, [int]$Quality = 88)
|
||||||
|
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
|
||||||
|
$params = New-Object System.Drawing.Imaging.EncoderParameters 1
|
||||||
|
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), ([int]$Quality)
|
||||||
|
$Bitmap.Save($Path, $codec, $params)
|
||||||
|
$params.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resize-Shot {
|
||||||
|
param([string]$In, [string]$Out, [int]$MaxWidth = 1600, [int]$Quality = 88)
|
||||||
|
$src = [System.Drawing.Image]::FromFile($In)
|
||||||
|
try {
|
||||||
|
$scale = [Math]::Min(1.0, $MaxWidth / $src.Width)
|
||||||
|
$w = [int][Math]::Round($src.Width * $scale)
|
||||||
|
$h = [int][Math]::Round($src.Height * $scale)
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap $w, $h
|
||||||
|
try {
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.DrawImage($src, 0, 0, $w, $h)
|
||||||
|
$g.Dispose()
|
||||||
|
Save-Jpeg -Bitmap $bmp -Path $Out -Quality $Quality
|
||||||
|
"{0}`t{1}x{2}`t{3} KB" -f (Split-Path $Out -Leaf), $w, $h, [Math]::Round((Get-Item $Out).Length / 1KB)
|
||||||
|
} finally { $bmp.Dispose() }
|
||||||
|
} finally { $src.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = @(
|
||||||
|
@{ In = 'PeopleLib_bAecy2Izab.png'; Out = 'shot-library.jpg' }
|
||||||
|
@{ In = 'PeopleLib_34BtvLsqDE.png'; Out = 'shot-search.jpg' }
|
||||||
|
@{ In = 'PeopleLib_ScQMUA2D24.png'; Out = 'shot-reader.jpg' }
|
||||||
|
@{ In = 'PeopleLib_2N6zVpCFBA.png'; Out = 'shot-ai.jpg' }
|
||||||
|
@{ In = 'ai-send-confirmation.png'; Out = 'shot-ai-confirm.jpg' }
|
||||||
|
@{ In = 'pdf-annotations.png'; Out = 'shot-annotations.jpg' }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($m in $map) {
|
||||||
|
$in = Join-Path $srcShots $m.In
|
||||||
|
if (Test-Path $in) { Resize-Shot -In $in -Out (Join-Path $outDir $m.Out) }
|
||||||
|
else { Write-Warning "缺少源图 $in" }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($v in @('light', 'dark')) {
|
||||||
|
foreach ($size in @(32, 256, 512)) {
|
||||||
|
$in = Join-Path $srcIcons "$v\icon-$size.png"
|
||||||
|
if (Test-Path $in) { Copy-Item $in (Join-Path $outDir "icon-$v-$size.png") -Force }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Open Graph 封面:深色底 + 应用图标 + 书库截图,1200x630。
|
||||||
|
$og = New-Object System.Drawing.Bitmap 1200, 630
|
||||||
|
try {
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($og)
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
|
||||||
|
$g.Clear([System.Drawing.ColorTranslator]::FromHtml('#12141a'))
|
||||||
|
|
||||||
|
$shot = Join-Path $outDir 'shot-library.jpg'
|
||||||
|
if (Test-Path $shot) {
|
||||||
|
$img = [System.Drawing.Image]::FromFile($shot)
|
||||||
|
try {
|
||||||
|
$targetW = 660
|
||||||
|
$scale = $targetW / $img.Width
|
||||||
|
$h = [int][Math]::Round($img.Height * $scale)
|
||||||
|
$g.DrawImage($img, 500, [int]((630 - $h) / 2), $targetW, $h)
|
||||||
|
} finally { $img.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$iconPath = Join-Path $outDir 'icon-dark-256.png'
|
||||||
|
if (Test-Path $iconPath) {
|
||||||
|
$icon = [System.Drawing.Image]::FromFile($iconPath)
|
||||||
|
try { $g.DrawImage($icon, 72, 132, 112, 112) } finally { $icon.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$white = New-Object System.Drawing.SolidBrush ([System.Drawing.ColorTranslator]::FromHtml('#f4f5f7'))
|
||||||
|
$muted = New-Object System.Drawing.SolidBrush ([System.Drawing.ColorTranslator]::FromHtml('#a8aeba'))
|
||||||
|
$fTitle = New-Object System.Drawing.Font 'Microsoft YaHei', 44, ([System.Drawing.FontStyle]::Bold)
|
||||||
|
$fSub = New-Object System.Drawing.Font 'Microsoft YaHei', 19
|
||||||
|
$fFoot = New-Object System.Drawing.Font 'Microsoft YaHei', 15
|
||||||
|
$g.DrawString('PeopleLib', $fTitle, $white, 68, 268)
|
||||||
|
$g.DrawString("多源文献检索`n本地书库与内置阅读器", $fSub, $muted, 72, 348)
|
||||||
|
$g.DrawString('reader.mesalogo.com', $fFoot, $muted, 72, 470)
|
||||||
|
$g.Dispose()
|
||||||
|
Save-Jpeg -Bitmap $og -Path (Join-Path $outDir 'og-cover.jpg') -Quality 86
|
||||||
|
"og-cover.jpg`t1200x630`t{0} KB" -f [Math]::Round((Get-Item (Join-Path $outDir 'og-cover.jpg')).Length / 1KB)
|
||||||
|
} finally { $og.Dispose() }
|
||||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,722 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", "Noto Sans CJK SC",
|
||||||
|
sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono",
|
||||||
|
"DejaVu Sans Mono", monospace;
|
||||||
|
|
||||||
|
--bg: #fbfaf7;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
--bg-subtle: #f2f0eb;
|
||||||
|
--bg-code: #f0eee8;
|
||||||
|
--text: #1b1c20;
|
||||||
|
--text-muted: #55585f;
|
||||||
|
--text-faint: #6d7078;
|
||||||
|
--border: #ded9cf;
|
||||||
|
--border-strong: #c6c0b3;
|
||||||
|
--accent: #8a6110;
|
||||||
|
--accent-hover: #6d4b0b;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--accent-soft: #f6eeda;
|
||||||
|
--focus: #1b5ea8;
|
||||||
|
--shadow: 0 1px 2px rgba(27, 28, 32, .05), 0 8px 24px rgba(27, 28, 32, .07);
|
||||||
|
--radius: 12px;
|
||||||
|
--measure: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #12141a;
|
||||||
|
--bg-elevated: #1a1d24;
|
||||||
|
--bg-subtle: #1f232b;
|
||||||
|
--bg-code: #22262f;
|
||||||
|
--text: #f0f1f4;
|
||||||
|
--text-muted: #b3b8c4;
|
||||||
|
--text-faint: #9aa0ad;
|
||||||
|
--border: #2e333d;
|
||||||
|
--border-strong: #414753;
|
||||||
|
--accent: #e0b256;
|
||||||
|
--accent-hover: #f0c672;
|
||||||
|
--accent-contrast: #17181c;
|
||||||
|
--accent-soft: #2a2519;
|
||||||
|
--focus: #7ab6f0;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 28px rgba(0, 0, 0, .34);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
* { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.75;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
margin: 0 0 .6em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: clamp(1.9rem, 1.3rem + 2.4vw, 3rem); }
|
||||||
|
h2 { font-size: clamp(1.45rem, 1.2rem + 1.1vw, 1.95rem); }
|
||||||
|
h3 { font-size: 1.16rem; }
|
||||||
|
|
||||||
|
p, ul, ol, dl, table, figure, pre {
|
||||||
|
margin: 0 0 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, samp, pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code:not(pre code) {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: .1em .38em;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .95rem 1.05rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: .05em .4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
border-radius: 0 0 var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1080px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: clamp(1rem, 4vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--bg);
|
||||||
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||||
|
backdrop-filter: saturate(1.4) blur(10px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (backdrop-filter: blur(2px)) {
|
||||||
|
.site-header { background: var(--bg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header .wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem 1.5rem;
|
||||||
|
padding-block: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .brand-light { display: block; }
|
||||||
|
.brand .brand-dark { display: none; }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.brand .brand-light { display: none; }
|
||||||
|
.brand .brand-dark { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .2rem .35rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
display: block;
|
||||||
|
padding: .35rem .68rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a[aria-current="page"] {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { display: block; }
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding-block: clamp(2.6rem, 5vw, 4.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
section + section {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
max-width: var(--measure);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-block: clamp(2.6rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: clamp(2rem, 4vw, 3.2rem);
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.hero-grid { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p.lede {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 46ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 { margin-bottom: .5em; }
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .22rem .8rem;
|
||||||
|
font-size: .86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .7rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .62rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-center { justify-content: center; }
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot figcaption {
|
||||||
|
padding: .65rem .9rem;
|
||||||
|
font-size: .88rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .92rem;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(135deg,
|
||||||
|
var(--bg-subtle) 0 12px,
|
||||||
|
var(--bg-elevated) 12px 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin-bottom: .45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p:last-child,
|
||||||
|
.card ul:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card ul {
|
||||||
|
padding-inline-start: 1.15rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .card-meta {
|
||||||
|
display: block;
|
||||||
|
font-size: .82rem;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul, .prose ol {
|
||||||
|
padding-inline-start: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li + li {
|
||||||
|
margin-top: .35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li > strong:first-child {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-inline-start: 4px solid var(--accent);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
margin: 0 0 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.callout h3 {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
margin-bottom: .35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0;
|
||||||
|
font-size: .96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
caption-side: top;
|
||||||
|
text-align: start;
|
||||||
|
padding: .8rem 1rem 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: start;
|
||||||
|
padding: .62rem .9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
font-size: .88rem;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yes { color: var(--accent); font-weight: 700; }
|
||||||
|
.no { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
counter-reset: step;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps > li {
|
||||||
|
counter-increment: step;
|
||||||
|
position: relative;
|
||||||
|
padding-inline-start: 2.6rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps > li::before {
|
||||||
|
content: counter(step);
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
top: .18em;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps > li > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.faq {
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq > h2 {
|
||||||
|
font-size: .84rem;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin: 2rem 0 .8rem;
|
||||||
|
padding-bottom: .4rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq > h2:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.faq details {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
padding: 0 1.1rem;
|
||||||
|
margin-bottom: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 620;
|
||||||
|
padding: .85rem 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: .6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.faq summary::before {
|
||||||
|
content: "+";
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq details[open] summary::before { content: "-"; }
|
||||||
|
|
||||||
|
.faq details[open] summary {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq .faq-body {
|
||||||
|
padding: .9rem 0 .3rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq .faq-body > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
gap: .1rem 1.2rem;
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 620px) {
|
||||||
|
.kv { grid-template-columns: max-content minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dt {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dd {
|
||||||
|
margin: 0 0 .55rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .45rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-list li {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .2rem .78rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta .wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta h2 { margin-bottom: .25em; }
|
||||||
|
.cta p { margin-bottom: 0; color: var(--text-muted); }
|
||||||
|
.cta .btn-row { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
padding-block: 2.2rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer .wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.4rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer h2 {
|
||||||
|
font-size: .82rem;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer li { margin-bottom: .3rem; }
|
||||||
|
|
||||||
|
.site-footer .legal {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
margin: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center { text-align: center; }
|
||||||
|
|
||||||
|
.notfound {
|
||||||
|
min-height: 52vh;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>下载与安装 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 下载与安装说明。Windows x64 免安装版解压即用,用户数据在程序同级 data 目录;macOS arm64 提供 DMG,因为是 ad-hoc 签名未经 Apple 公证,首次打开需要右键选择打开。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/download.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="下载与安装 - PeopleLib">
|
||||||
|
<meta property="og:description" content="Windows 免安装版与 macOS DMG 的完整安装步骤,包含 macOS 首次打开的处理方法。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/download.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html" aria-current="page">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">下载</span>
|
||||||
|
<h1>获取 PeopleLib</h1>
|
||||||
|
<p>
|
||||||
|
所有正式版本都通过项目的 GitHub Releases 发布,不通过 npm 或应用商店分发。
|
||||||
|
当前版本 2.0.0,MIT 许可,免费。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="https://github.com/lofyer/peoplelib/releases/latest" rel="noopener">前往最新发布页</a>
|
||||||
|
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/releases" rel="noopener">查看全部版本</a>
|
||||||
|
</p>
|
||||||
|
<p class="hint">请只从上面的 GitHub Releases 页面下载。本站不直接托管安装包。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="windows">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">Windows</span>
|
||||||
|
<h2>Windows x64 免安装版</h2>
|
||||||
|
<p>没有安装程序,不写注册表,解压就能用。</p>
|
||||||
|
</div>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>
|
||||||
|
<p>在 Releases 页面下载 Windows x64 的压缩包。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p><strong>完整解压到一个固定目录</strong>。不要在压缩包里直接双击运行,那样程序找不到自己的资源文件。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>双击目录中的 <code>PeopleLib.exe</code> 启动。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p><strong>保留整个程序目录</strong>,不要只把 exe 移到别处。用户数据保存在程序同级的 <code>data/</code> 目录里。</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="callout">
|
||||||
|
<h3>更新到新版本</h3>
|
||||||
|
<p>
|
||||||
|
先完全退出正在运行的 <code>PeopleLib.exe</code>,再用新版本的文件覆盖程序目录。
|
||||||
|
<code>data/</code> 目录不要替换,书库、笔记与批注都在里面。想换目录的话,把整个目录连 <code>data/</code> 一起搬走。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Windows 可能对下载来的可执行文件提示来源不明,这是未购买代码签名证书的常见表现。
|
||||||
|
如果你不确定,可以先在 Releases 页面核对文件来源。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="macos">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">macOS</span>
|
||||||
|
<h2>macOS arm64 DMG</h2>
|
||||||
|
<p>适用于 Apple Silicon 机型。安装包为 ad-hoc 签名,没有经过 Apple 公证。</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="callout">
|
||||||
|
<h3>首次打开会被系统拦下,这是正常的</h3>
|
||||||
|
<p>
|
||||||
|
应用用的是 ad-hoc 签名,没有付费开发者账号,也没有走 Apple 的公证流程。
|
||||||
|
所以 macOS 首次会提示无法验证开发者,或者说文件已损坏。这不是病毒,而是未公证应用的标准提示,
|
||||||
|
按下面的步骤打开一次之后就不会再出现。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>
|
||||||
|
<p>在 Releases 页面下载 macOS arm64 的 <code>.dmg</code> 文件。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>双击挂载 DMG,把 <strong>PeopleLib</strong> 拖进「应用程序」文件夹。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
在「应用程序」里 <strong>按住 Control 点击</strong>(或右键)PeopleLib 图标,选择「打开」,
|
||||||
|
然后在弹出的对话框里再点一次「打开」。直接双击是不行的,必须走右键菜单这条路径,
|
||||||
|
因为只有它会给出确认打开的选项。
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
如果仍然被拦,打开「系统设置」,进入「隐私与安全性」,在页面下方找到刚被阻止的 PeopleLib,
|
||||||
|
点击「仍要打开」。
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
macOS 版的用户数据在 <code>~/Library/Application Support/PeopleLib</code>,不在应用包里。
|
||||||
|
升级时直接覆盖「应用程序」里的旧版本,数据不受影响。
|
||||||
|
</p>
|
||||||
|
<p class="hint">Intel 机型目前没有提供构建版本。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="first-run">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">上手</span>
|
||||||
|
<h2>第一次启动之后做什么</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<ol>
|
||||||
|
<li><strong>需要代理就先配代理</strong>。设置里配一次,对所有数据源与封面请求同时生效。</li>
|
||||||
|
<li><strong>试一次检索</strong>。可以先只选一个数据源,确认网络通畅,再切到聚合搜索。</li>
|
||||||
|
<li><strong>导入已有的书</strong>。本地已有的 PDF、EPUB 等文件可以直接导入书库,不必来自检索结果。</li>
|
||||||
|
<li><strong>想用 AI 就填自己的接口</strong>。填接口地址与 API Key,选择协议类型。不填也能正常阅读,AI 是可选的。</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="from-source">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">开发者</span>
|
||||||
|
<h2>从源码运行</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
源码运行、打包发布、代理与账号配置、数据位置、项目结构与测试说明都在源码仓库的 <code>BUILD.md</code> 里。
|
||||||
|
公开仓库只发布二进制与使用说明。
|
||||||
|
</p>
|
||||||
|
<p>运行环境要求 Node.js 22.19.0 或更高版本。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>装完遇到问题</h2>
|
||||||
|
<p>常见问题页覆盖了 macOS 拦截、数据位置与格式支持。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="faq.html">查看常见问题</a>
|
||||||
|
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/issues" rel="noopener">提交问题反馈</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>常见问题 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 常见问题:AI 助手要不要花钱、数据存在哪里、支持哪些格式、macOS 提示无法验证开发者怎么办、能不能不联网使用、超大 PDF 会不会卡。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/faq.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="常见问题 - PeopleLib">
|
||||||
|
<meta property="og:description" content="AI 费用、数据位置、格式支持、macOS 首次打开被拦、离线使用与超大 PDF 的处理方式。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/faq.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html" aria-current="page">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">常见问题</span>
|
||||||
|
<h1>常见问题</h1>
|
||||||
|
<p>点击问题展开答案。没有覆盖到的情况可以到仓库提 issue。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq">
|
||||||
|
|
||||||
|
<h2>费用与账号</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>PeopleLib 本身要钱吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>不要。项目以 MIT 许可开源,没有付费版、订阅或功能解锁。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>AI 助手要不要花钱</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
取决于你自己接的服务。应用不内置任何厂商的 AI 服务,也不代收任何费用。
|
||||||
|
你在设置里填自己的接口地址与 API Key,费用由你和那家服务商结算,账单也在他们那边看。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
如果你接的是本地跑的模型,或者某个免费额度内的接口,那就不花钱。
|
||||||
|
不填 AI 配置的话,检索、书库与阅读功能都能正常使用,AI 是可选的。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>需要注册账号吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
PeopleLib 自己没有账号体系,不需要注册也不需要登录。
|
||||||
|
个别数据源(例如需要登录才能取下载直链的站点)要用你自己在那个站点的凭据,凭据保存在本地。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>数据与隐私</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>我的数据存在哪里</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>全部在本机。书库条目、下载的文件、封面缓存、笔记、批注与阅读进度都写在本地数据目录里,没有云端副本。</p>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Windows</dt>
|
||||||
|
<dd>程序目录同级的 <code>data/</code></dd>
|
||||||
|
<dt>macOS</dt>
|
||||||
|
<dd><code>~/Library/Application Support/PeopleLib</code></dd>
|
||||||
|
</dl>
|
||||||
|
<p>备份就是复制这个目录。彻底删除就是删掉它,不需要在任何地方注销。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>我的书和笔记会被上传吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
不会。应用没有服务端,也没有遥测上报。唯一会把正文内容发出去的情况是你主动使用 AI 助手并确认发送,
|
||||||
|
目标是你自己填写的那个接口地址。详见<a href="privacy.html">隐私与安全</a>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>AI 会不会把整本书悄悄发出去</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
不会。上下文范围由你选,选「全文」时无论文档多短都强制弹确认框,并显示确切字数。
|
||||||
|
界面上显示的字数就是真正外发的字数,应用不在本地偷偷截断,也不会在你没确认时发送。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>我的 API Key 安全吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
Key 经操作系统提供的 safeStorage 加密后落盘,不是明文配置文件,也不会进入界面进程,
|
||||||
|
日志与错误信息里也不会出现。请求由主进程直接发往你填写的地址,中间没有第三方转发。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>能完全离线使用吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
可以。已经下载进书库的文件,阅读、批注与笔记都不需要网络。封面已经缓存在本地,离线也能正常显示。
|
||||||
|
需要联网的只有检索、下载、AI 请求与检查更新这四件事。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>格式与阅读</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>支持哪些格式</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>内置阅读支持四类:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>PDF</strong>:页面批注、书签、选文与笔记,渲染画质可调</li>
|
||||||
|
<li><strong>EPUB</strong>:目录、重排、书签、选文与笔记</li>
|
||||||
|
<li><strong>MOBI / AZW / AZW3</strong>:无 DRM 的 MOBI、KF7 与 KF8 内容</li>
|
||||||
|
<li><strong>TXT / Markdown</strong>:自动识别编码,Markdown 渲染标题、列表、代码块与表格</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<strong>DJVU、FB2、CBZ、CBR</strong> 可以入库与整理,但没有内置阅读器,阅读要调用系统关联应用。
|
||||||
|
完整对照表在<a href="index.html#formats">首页</a>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Kindle 买的书能读吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
带 DRM 的文件不能。PeopleLib 不解除 DRM,遇到受保护的 MOBI、AZW、AZW3、KFX 或 Topaz 文件会明确拒绝,
|
||||||
|
并提示改用系统关联的应用打开。无 DRM 的 MOBI、KF7 与 KF8 可以正常阅读。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>几百兆的 PDF 会不会卡死</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
超大 PDF 走分段读取,不会把整个文件读进内存,所以打开一本很大的书不会一次性吃掉几百兆内存。
|
||||||
|
如果渲染感觉吃力,可以在底栏把画质档位调低。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>TXT 打开是乱码怎么办</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
应用会自动探测 UTF-8、带 BOM 与不带 BOM 的 UTF-16,以及 GB18030。
|
||||||
|
如果仍然乱码,说明文件用了这几种之外的编码,可以先用文本编辑器另存为 UTF-8 再导入。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>笔记能导出吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>可以。摘录与笔记支持导出,方便带到别的工具里继续整理。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>安装与运行</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>macOS 提示无法验证开发者,或者说文件已损坏</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
这是未公证应用的标准提示,不是文件损坏,也不是病毒。macOS 版用的是 ad-hoc 签名,
|
||||||
|
没有付费开发者账号,也没有走 Apple 的公证流程,所以系统首次会拦下来。
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>先把 PeopleLib 拖进「应用程序」文件夹。</li>
|
||||||
|
<li><strong>按住 Control 点击</strong>(或右键)应用图标,选择「打开」,在弹出的对话框里再点一次「打开」。直接双击不行,必须走右键菜单,只有这条路径会给出确认选项。</li>
|
||||||
|
<li>如果仍被拦,打开「系统设置」,进入「隐私与安全性」,在页面下方找到被阻止的 PeopleLib,点击「仍要打开」。</li>
|
||||||
|
</ol>
|
||||||
|
<p>成功打开一次之后系统会记住这个选择,之后正常双击即可。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Windows 版怎么安装</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
没有安装程序。下载压缩包后<strong>完整解压</strong>到一个固定目录,双击里面的 <code>PeopleLib.exe</code> 就能用。
|
||||||
|
不要在压缩包里直接双击运行,也不要把 exe 单独移出来,程序需要同目录下的资源文件。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>更新版本会不会丢数据</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
不会,但要注意操作顺序。Windows 上先完全退出正在运行的 <code>PeopleLib.exe</code>,再用新文件覆盖程序目录,
|
||||||
|
<code>data/</code> 目录保持原样不要替换。macOS 上数据在 <code>~/Library/Application Support/PeopleLib</code>,
|
||||||
|
直接覆盖「应用程序」里的旧版本即可。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>能换程序目录或者搬到别的电脑吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
Windows 版是便携布局,把整个程序目录连同 <code>data/</code> 一起复制走就行,
|
||||||
|
不要只搬 exe,也不要把 <code>data/</code> 落在旧目录里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>搜不到结果或者下载失败</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
数据源的可用性取决于站点自身状态。有的站点启用了人机验证,有的接口只提供最新列表而不支持关键词搜索,
|
||||||
|
有的下载需要该站点的账号。应用遇到这些情况会给出明确提示。
|
||||||
|
</p>
|
||||||
|
<p>如果所有源都不通,先检查设置里的代理配置,它对所有数据源与封面请求统一生效。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>有 Linux 版、移动端或浏览器插件吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
目前发布的是 Windows x64 免安装版与 macOS arm64 DMG。没有移动端应用,也没有浏览器插件。
|
||||||
|
项目源码开放,其他平台可自行尝试从源码运行。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>问题没解决</h2>
|
||||||
|
<p>到仓库开一个 issue,说明系统版本与复现步骤。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="https://github.com/lofyer/peoplelib/issues" rel="noopener">提交问题反馈</a>
|
||||||
|
<a class="btn btn-secondary" href="download.html">回到下载页</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>功能 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 的完整功能说明:12 个数据源统一检索、本地书库与书架标签、PDF/EPUB/MOBI/TXT 内置阅读器、读书笔记与画布笔记、自带密钥的 AI 助手、全局代理与镜像故障转移。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/features.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="功能 - PeopleLib">
|
||||||
|
<meta property="og:description" content="多源检索、本地书库、内置阅读器、笔记与批注、AI 助手、代理与镜像容错的完整说明。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/features.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html" aria-current="page">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">功能</span>
|
||||||
|
<h1>已经实现的部分</h1>
|
||||||
|
<p>这一页只写应用里真实存在的能力。没有列出的功能就是没有做。</p>
|
||||||
|
</div>
|
||||||
|
<ul class="pill-list">
|
||||||
|
<li><a href="#search">多源检索</a></li>
|
||||||
|
<li><a href="#library">本地书库</a></li>
|
||||||
|
<li><a href="#reader">内置阅读器</a></li>
|
||||||
|
<li><a href="#notes">笔记与批注</a></li>
|
||||||
|
<li><a href="#ai">AI 助手</a></li>
|
||||||
|
<li><a href="#network">代理与镜像</a></li>
|
||||||
|
<li><a href="#updates">版本更新</a></li>
|
||||||
|
<li><a href="#not-included">不包含的功能</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="search">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">检索</span>
|
||||||
|
<h2>12 个数据源,一套流程</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
检索、查看详情、下载文件这三步对所有数据源都是同一套界面与同一套交互。可以只搜某一个源,
|
||||||
|
也可以聚合全部源一起搜。不同站点的字段差异由应用内部归一,你看到的是统一的条目列表。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
数据源的可用性取决于站点自身状态。有的站点启用了人机验证,有的接口只提供最新列表而不支持关键词搜索。
|
||||||
|
遇到这类限制时应用会给出明确提示,不会假装搜索成功却返回空结果。完整的数据源清单见
|
||||||
|
<a href="https://github.com/lofyer/peoplelib" rel="noopener">仓库说明</a>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-search.jpg" width="1600" height="1086" alt="多源检索界面,顶部是搜索框与数据源选择,下方列出检索结果条目" loading="lazy">
|
||||||
|
<figcaption>检索结果与数据源筛选</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="library">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">归档</span>
|
||||||
|
<h2>本地书库</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>收藏与下载</dt>
|
||||||
|
<dd>检索到的条目可以先收藏,再决定是否下载文件。下载完成后自动登记进书库。</dd>
|
||||||
|
<dt>封面缓存</dt>
|
||||||
|
<dd>封面下载后缓存在本地,离线也能正常显示网格视图。</dd>
|
||||||
|
<dt>书架与标签</dt>
|
||||||
|
<dd>用书架做粗分类,用标签做交叉归类,同一本书可以属于多个标签。</dd>
|
||||||
|
<dt>批量整理</dt>
|
||||||
|
<dd>支持多选,批量移动书架、批量打标签、批量删除。</dd>
|
||||||
|
<dt>阅读状态</dt>
|
||||||
|
<dd>记录每本书的阅读状态与进度,重新打开时回到上次的位置。</dd>
|
||||||
|
<dt>本地导入</dt>
|
||||||
|
<dd>已有的文件可以直接导入书库,不必来自检索结果。</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="reader">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">阅读</span>
|
||||||
|
<h2>内置阅读器</h2>
|
||||||
|
<p>四类格式在应用内直接打开,不需要外部程序。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<h3>PDF</h3>
|
||||||
|
<ul>
|
||||||
|
<li>页面批注、书签、选文与笔记</li>
|
||||||
|
<li>渲染画质分三档可调并记住选择</li>
|
||||||
|
<li>超大文件分段读取,不整文件载入内存</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>EPUB</h3>
|
||||||
|
<ul>
|
||||||
|
<li>目录导航与文字重排</li>
|
||||||
|
<li>书签、选文与笔记</li>
|
||||||
|
<li>按章节与偏移量记录阅读位置</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>MOBI / AZW / AZW3</h3>
|
||||||
|
<ul>
|
||||||
|
<li>使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8</li>
|
||||||
|
<li>DRM 保护的文件明确拒绝,不尝试绕过</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>TXT / Markdown</h3>
|
||||||
|
<ul>
|
||||||
|
<li>自动识别 UTF-8、UTF-16 与 GB18030 编码</li>
|
||||||
|
<li>Markdown 渲染标题、列表、代码块与表格</li>
|
||||||
|
<li>与 EPUB 同一套重排与定位机制</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-reader.jpg" width="1600" height="1050" alt="内置 PDF 阅读器,页面上带有高亮批注,右侧是笔记面板" loading="lazy">
|
||||||
|
<figcaption>PDF 阅读与批注</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="notes">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">批注</span>
|
||||||
|
<h2>笔记与批注</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
批注直接落在页面上,和书目条目绑定。笔记分两种形态:读书笔记是文字为主的富文本,
|
||||||
|
画布笔记可以自由绘制,并且能把 PDF 的某一页当作底版,在上面圈画标记。
|
||||||
|
</p>
|
||||||
|
<p>选中的段落可以摘录进笔记,笔记与摘录支持导出,方便带到别的工具里继续用。</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-annotations.jpg" width="1600" height="1058" alt="PDF 页面上的批注工具与已有的高亮标记" loading="lazy">
|
||||||
|
<figcaption>批注工具</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ai">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">AI</span>
|
||||||
|
<h2>自带密钥的 AI 助手</h2>
|
||||||
|
<p>应用不内置任何厂商服务,接口地址与密钥都由你填。</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>支持三类协议:Anthropic 的 <code>/messages</code>、OpenAI Responses 的 <code>/responses</code>,以及兼容 Chat 的 <code>/chat/completions</code>。任何符合这三类协议的服务都能接。</p>
|
||||||
|
<h3>五种上下文范围</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>选中文本</strong>:只发你选中的那一段,需要先选中。</li>
|
||||||
|
<li><strong>当前页</strong>:发当前页的正文,不依赖选区。</li>
|
||||||
|
<li><strong>全文</strong>:发整篇正文,不依赖选区,发送前强制确认。</li>
|
||||||
|
<li><strong>页面图像</strong>:把当前页渲染成图片发送,适合公式与图表。</li>
|
||||||
|
<li><strong>框选区域</strong>:在页面上拉一个框,只发框内的图像。</li>
|
||||||
|
</ul>
|
||||||
|
<h3>关于字数</h3>
|
||||||
|
<p>
|
||||||
|
正文完整发送,本地不做截断。界面上显示的字数就是真正外发的字数,两者一致。
|
||||||
|
如果超出模型的上下文限制,应用会把接口返回的报错转成中文提示,而不是悄悄少发一部分内容。
|
||||||
|
</p>
|
||||||
|
<p>对话支持多轮追问,上下文范围在会话中可以随时切换。</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai.jpg" width="1474" height="1125" alt="AI 助手侧栏,展示上下文范围选择与对话内容" loading="lazy">
|
||||||
|
<figcaption>AI 助手侧栏与上下文范围选择</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="network">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">网络</span>
|
||||||
|
<h2>代理与镜像容错</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>全局代理</dt>
|
||||||
|
<dd>在设置里配一次,对所有数据源请求与封面请求同时生效,不需要逐个源单独设置。</dd>
|
||||||
|
<dt>镜像故障转移</dt>
|
||||||
|
<dd>某个镜像不可用时自动切到下一个,原镜像恢复后自动重新启用,不需要手动重置。</dd>
|
||||||
|
<dt>失败提示</dt>
|
||||||
|
<dd>站点侧的人机验证、登录过期与超时都会转成明确的中文提示。</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="updates">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">维护</span>
|
||||||
|
<h2>版本更新</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
可以在设置里手动检查更新,也可以打开启动时自动检查。检查的对象是项目的 GitHub Releases,
|
||||||
|
发现新版本后应用会打开对应的下载页。更新前退出旧版本再覆盖程序文件,用户数据目录不需要替换。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="not-included">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">边界</span>
|
||||||
|
<h2>不包含的功能</h2>
|
||||||
|
<p>写清楚没有什么,比含糊其辞更省你的时间。</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<ul>
|
||||||
|
<li>没有云同步,也没有账号体系。</li>
|
||||||
|
<li>没有移动端应用,也没有浏览器插件。</li>
|
||||||
|
<li>没有多人协作或共享书库。</li>
|
||||||
|
<li>DJVU、FB2、CBZ、CBR 只能入库与整理,阅读要调用系统关联应用。</li>
|
||||||
|
<li>不解除 DRM。受保护的文件会被明确拒绝并提示改用系统关联应用。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>看完想试试</h2>
|
||||||
|
<p>下载页有 Windows 与 macOS 的完整安装说明。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="download.html">前往下载</a>
|
||||||
|
<a class="btn btn-secondary" href="privacy.html">先看隐私说明</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PeopleLib:多源文献检索、本地书库与内置阅读器</title>
|
||||||
|
<meta name="description" content="PeopleLib 是一款桌面文献客户端,在一个界面里检索 12 个公开文献源,下载归档到本地书库,并用内置阅读器阅读 PDF、EPUB、MOBI 与 TXT/Markdown。书库、笔记与批注全部存在本地,AI 助手自带密钥。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="PeopleLib:多源文献检索、本地书库与内置阅读器">
|
||||||
|
<meta property="og:description" content="在一个桌面客户端里检索 12 个公开文献源,下载归档到本地书库,用内置阅读器阅读并批注。数据留在本地,AI 助手自带密钥。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="PeopleLib:多源文献检索、本地书库与内置阅读器">
|
||||||
|
<meta name="twitter:description" content="在一个桌面客户端里检索 12 个公开文献源,下载归档到本地书库,用内置阅读器阅读并批注。数据留在本地,AI 助手自带密钥。">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html" aria-current="page">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div class="wrap hero-grid">
|
||||||
|
<div>
|
||||||
|
<p class="tagline">Windows 与 macOS 桌面应用</p>
|
||||||
|
<h1>检索、收藏、阅读,都在自己的电脑里完成</h1>
|
||||||
|
<p class="lede">
|
||||||
|
PeopleLib 把 12 个公开文献源的检索、详情与下载收进同一个界面,下载的文件直接进本地书库,
|
||||||
|
用内置阅读器打开并做批注。书库、笔记与阅读进度都留在你的磁盘上,不经过任何服务器。
|
||||||
|
</p>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="download.html">下载 PeopleLib</a>
|
||||||
|
<a class="btn btn-secondary" href="features.html">查看功能</a>
|
||||||
|
</p>
|
||||||
|
<p class="hint">当前版本 2.0.0,MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-library.jpg" width="1600" height="1086" alt="PeopleLib 书库界面,左侧是书架与标签,右侧网格展示带封面的图书条目" fetchpriority="high">
|
||||||
|
<figcaption>本地书库:书架、标签、封面缓存与阅读状态</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="highlights">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">核心能力</span>
|
||||||
|
<h2>一条完整的链路,从找到文献到读完做笔记</h2>
|
||||||
|
<p>没有账号,没有云端,没有订阅。每一步都在本机完成。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">检索</span>
|
||||||
|
<h3>多源统一检索</h3>
|
||||||
|
<p>12 个数据源共用同一套搜索、详情与下载流程,可指定单个源,也可聚合全部。数据源可用性取决于站点自身状态,程序会给出明确提示而不是静默失败。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">归档</span>
|
||||||
|
<h3>本地书库</h3>
|
||||||
|
<p>收藏条目、下载文件、缓存封面,用书架与标签整理,支持多选批量操作,并记录每本书的阅读状态。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">阅读</span>
|
||||||
|
<h3>内置阅读器</h3>
|
||||||
|
<p>PDF、EPUB、无 DRM 的 MOBI/AZW/AZW3 与 TXT/Markdown 都在应用内打开,保留进度、书签与选文。超大 PDF 分段读取,不把整个文件读进内存。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">批注</span>
|
||||||
|
<h3>笔记与批注</h3>
|
||||||
|
<p>读书笔记与画布笔记两种形态,画布笔记可以拿 PDF 页面当底版自由标注,摘录与笔记可导出。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">AI</span>
|
||||||
|
<h3>自带密钥的 AI 助手</h3>
|
||||||
|
<p>填自己的接口地址与 API Key,支持选中文本、当前页、全文、页面图像与框选区域五种上下文范围,可多轮追问。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">网络</span>
|
||||||
|
<h3>代理与镜像容错</h3>
|
||||||
|
<p>代理一处配置对所有数据源与封面请求生效。镜像失效自动切换,恢复后自动重新启用。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="privacy-teaser">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">设计取舍</span>
|
||||||
|
<h2>本地优先不是宣传语,是架构约束</h2>
|
||||||
|
<p>这些限制写在代码的边界里,不是可以在设置里关掉的开关。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<h3>数据不出本机</h3>
|
||||||
|
<p>书库、笔记、批注与阅读进度都存在本地文件里。应用没有账号体系,也没有同步服务端。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>AI 密钥由系统加密保管</h3>
|
||||||
|
<p>没有内置任何厂商服务。API Key 经操作系统的 safeStorage 加密后落盘,不进渲染进程,不写日志。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>正文不会自动外发</h3>
|
||||||
|
<p>只有你确认后才发送。选择「全文」范围时无论文档多短都强制弹确认框,并显示确切字数。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>渲染层拿不到任意路径</h3>
|
||||||
|
<p>界面进程不能直接读盘,所有文件访问都要经主进程白名单校验,只放行书库中真实登记的条目。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p><a href="privacy.html">阅读完整的隐私与安全说明</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screens">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">界面预览</span>
|
||||||
|
<h2>真实截图</h2>
|
||||||
|
<p>以下画面来自 Windows 版本,界面为中文。</p>
|
||||||
|
</div>
|
||||||
|
<div class="gallery">
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-search.jpg" width="1600" height="1086" alt="多源检索界面,顶部是搜索框与数据源选择,下方列出检索结果条目" loading="lazy">
|
||||||
|
<figcaption>多源检索,可选单个数据源或聚合全部</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-reader.jpg" width="1600" height="1050" alt="内置 PDF 阅读器,页面上带有高亮批注,右侧是笔记面板" loading="lazy">
|
||||||
|
<figcaption>内置阅读器与 PDF 批注</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai.jpg" width="1474" height="1125" alt="AI 助手侧栏,展示上下文范围选择与对话内容" loading="lazy">
|
||||||
|
<figcaption>AI 助手,可选五种上下文范围提问</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai-confirm.jpg" width="1600" height="1076" alt="发送前的确认对话框,显示即将外发的字数" loading="lazy">
|
||||||
|
<figcaption>发送前的确认框会显示确切字数</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="formats">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">格式</span>
|
||||||
|
<h2>能读什么,能管什么</h2>
|
||||||
|
<p>入库管理与内置阅读是两件事,下表分开列出,不夸大支持范围。</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<caption>格式支持对照</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">格式</th>
|
||||||
|
<th scope="col">入库与管理</th>
|
||||||
|
<th scope="col">内置阅读</th>
|
||||||
|
<th scope="col">说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">PDF</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>页面批注、书签、选文与笔记,可调渲染画质</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">EPUB</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>目录、重排、书签、选文与笔记</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">MOBI / AZW / AZW3</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">TXT / Markdown</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>自动识别编码,Markdown 渲染标题、列表、代码块与表格</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">DJVU / FB2 / CBZ / CBR</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="no">不支持</span></td>
|
||||||
|
<td>可入库整理,阅读需调用系统关联应用</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint">DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,应用会明确提示改用系统关联应用打开。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>准备好开始了</h2>
|
||||||
|
<p>Windows 免安装解压即用,macOS 提供 arm64 DMG。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="download.html">前往下载</a>
|
||||||
|
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/releases" rel="noopener">GitHub Releases</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>隐私与安全 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 的隐私与安全设计:书库笔记批注全部留在本地,AI 助手完全自带密钥且密钥经系统 safeStorage 加密,正文只在用户确认后外发,渲染进程无法读取任意路径,不解除 DRM。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/privacy.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="隐私与安全 - PeopleLib">
|
||||||
|
<meta property="og:description" content="数据留在本地,AI 自带密钥,正文不自动外发,渲染层无法读取任意路径。这些是架构约束,不是可选开关。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/privacy.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html" aria-current="page">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">隐私与安全</span>
|
||||||
|
<h1>你的书库只属于你</h1>
|
||||||
|
<p>
|
||||||
|
下面这些不是隐私政策的模板文字,而是应用实际的架构约束。它们决定了某些事情在这个程序里做不到,
|
||||||
|
哪怕以后想加也要先推翻现有设计。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="callout">
|
||||||
|
<h3>一句话总结</h3>
|
||||||
|
<p>
|
||||||
|
PeopleLib 没有服务端。它唯一会主动联网的场合是你按下搜索或下载,以及你启用检查更新。
|
||||||
|
AI 请求发往你自己填的接口地址。除此之外没有任何数据外发。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="local-first">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">本地优先</span>
|
||||||
|
<h2>数据存在哪里</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>书库条目、下载的文件、封面缓存、读书笔记、画布笔记、批注与阅读进度,全部写在本机的数据目录里。没有账号,没有同步服务端,没有遥测上报。</p>
|
||||||
|
<p>AI 对话记录同样只存在本机,按会话分别存成文件,随书籍删除一并清理。发给视觉模型的图像也留在本地,供你回看历史时显示,后续追问不会重复上传。</p>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Windows</dt>
|
||||||
|
<dd>程序目录同级的 <code>data/</code>,便携布局。整个程序目录可以整体搬走或备份。</dd>
|
||||||
|
<dt>macOS</dt>
|
||||||
|
<dd><code>~/Library/Application Support/PeopleLib</code>。应用包在 DMG 里是只读的,数据不能放里面,升级覆盖也不会碰到这个目录。</dd>
|
||||||
|
</dl>
|
||||||
|
<p>想彻底删除数据,删掉上面对应的目录即可,不需要在任何地方注销账号。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ai-key">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">AI 密钥</span>
|
||||||
|
<h2>完全自带密钥,没有中间人</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
应用没有内置任何厂商服务,也没有代理转发层。你在设置里填自己的接口地址与 API Key,
|
||||||
|
请求从你的机器直接发到你填的那个地址。项目方看不到你的用量,也收不到你的费用。
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>密钥加密落盘</strong>:API Key 经操作系统提供的 safeStorage 加密后保存,不是明文配置文件。</li>
|
||||||
|
<li><strong>密钥不进渲染进程</strong>:界面进程拿不到密钥原文,实际请求由主进程发出。</li>
|
||||||
|
<li><strong>密钥不写日志</strong>:日志与错误信息里不会出现密钥或 token。</li>
|
||||||
|
<li><strong>Responses 协议不留存</strong>:走 OpenAI Responses 协议时请求带 <code>store: false</code>,不让服务端留存对话记录。</li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">注意:你选择的模型提供方会按它自己的政策处理收到的内容。PeopleLib 能控制的是不多发、不留副本、不经第三方转发。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="explicit-send">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">明确确认</span>
|
||||||
|
<h2>正文不会自动外发</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
打开一本书不会触发任何 AI 请求。只有你选定上下文范围并点击发送,内容才会离开本机。
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>选择「全文」范围时,无论文档多短都会强制弹出确认框,并提示可能超出模型的上下文限制。</li>
|
||||||
|
<li>确认框里显示的字数就是真正外发的字数。应用不做本地截断,界面数字与实际请求一致。</li>
|
||||||
|
<li>如果内容超出模型限制,应用把接口返回的报错转成中文提示,而不是悄悄少发一部分。</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
这条规则有过教训。早期版本按固定字数上限挖掉正文中间部分,结果 40 页的文档实际只发出 8 页,
|
||||||
|
而界面仍显示全文字数。那属于静默的数据丢失,用户还会把答非所问归因于模型,所以该行为已经移除。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai-confirm.jpg" width="1600" height="1076" alt="发送前的确认对话框,显示即将外发的字数与超出上下文限制的提示" loading="lazy">
|
||||||
|
<figcaption>发送确认框</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sandbox">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">进程边界</span>
|
||||||
|
<h2>界面进程读不到任意文件</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
Electron 应用最常见的风险是渲染进程能拿着任意路径去读盘。PeopleLib 把这条路堵住了:
|
||||||
|
渲染层不能传任意路径,所有文件访问都要经主进程的白名单校验,只放行书库中真实登记的条目。
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>路径白名单</strong>:不在书库里的路径一律拒绝,无论渲染层怎么构造参数。</li>
|
||||||
|
<li><strong>会话与发送方绑定</strong>:超大 PDF 的分段读取会话与请求方绑定,句柄不透明,界面窗口销毁后立即回收。</li>
|
||||||
|
<li><strong>Markdown 双重净化</strong>:Markdown 渲染禁用裸 HTML,再过一层 DOMPurify,防止文件内容里的脚本被执行。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="drm">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">合规</span>
|
||||||
|
<h2>不绕过 DRM</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
带 DRM 的 MOBI、AZW、AZW3、KFX 与 Topaz 文件,以及损坏或不兼容的文件,应用不会尝试解除保护。
|
||||||
|
遇到这类文件会直接给出提示,建议改用系统关联的应用打开。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
同样地,PeopleLib 只是对公开网络接口的客户端封装,不托管也不分发任何内容。
|
||||||
|
各数据源所提供作品的版权状态因司法辖区而异,使用者需自行确保用法符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="network-scope">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">联网范围</span>
|
||||||
|
<h2>什么时候会联网</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<caption>应用发起网络请求的全部场合</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">场合</th>
|
||||||
|
<th scope="col">目标</th>
|
||||||
|
<th scope="col">触发方式</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">检索与下载</th>
|
||||||
|
<td>你选择的数据源站点</td>
|
||||||
|
<td>你点击搜索或下载</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">封面加载</th>
|
||||||
|
<td>条目对应的封面地址</td>
|
||||||
|
<td>展示书库或检索结果</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">AI 请求</th>
|
||||||
|
<td>你自己填写的接口地址</td>
|
||||||
|
<td>你确认发送</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">检查更新</th>
|
||||||
|
<td>项目的 GitHub Releases</td>
|
||||||
|
<td>手动检查,或你启用了启动时检查</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p>所有这些请求都走你在设置里配置的全局代理,包括封面请求。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="site-privacy">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">本站点</span>
|
||||||
|
<h2>这个网站本身</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
本站是托管在 GitHub Pages 上的静态页面,没有分析脚本,没有 Cookie,没有第三方字体或 CDN 资源,
|
||||||
|
页面用的是你系统里已有的字体。作为托管方,GitHub 会按其自身政策记录访问日志,这一点本站无法控制。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>还有疑问</h2>
|
||||||
|
<p>常见问题里覆盖了费用、数据位置、格式支持与 macOS 首次打开。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="faq.html">查看常见问题</a>
|
||||||
|
<a class="btn btn-secondary" href="download.html">下载与安装</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://reader.mesalogo.com/sitemap.xml
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/features.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/privacy.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/download.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/faq.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const sessionsPath = require.resolve('../reader/ai-sessions.js');
|
||||||
|
const imagesPath = require.resolve('../reader/ai-images.js');
|
||||||
|
|
||||||
|
const dirs = [];
|
||||||
|
|
||||||
|
function tmp(tag) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
|
||||||
|
dirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fresh(tag = 'x') {
|
||||||
|
const dir = tmp(tag);
|
||||||
|
return at(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function at(dir) {
|
||||||
|
delete require.cache[sessionsPath];
|
||||||
|
delete require.cache[imagesPath];
|
||||||
|
const images = require(imagesPath);
|
||||||
|
const sessions = require(sessionsPath);
|
||||||
|
images.init(dir);
|
||||||
|
sessions.init(dir);
|
||||||
|
return { sessions, images, dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionDir(dir) {
|
||||||
|
return path.join(dir, 'reader-ai-sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionFile(dir, id) {
|
||||||
|
return path.join(sessionDir(dir), `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageDir(dir) {
|
||||||
|
return path.join(dir, 'reader-ai-images');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最小可解码 JPEG:SOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
|
||||||
|
function jpeg(seed, size = 64) {
|
||||||
|
const bytes = Buffer.alloc(size, seed & 0xff);
|
||||||
|
bytes[0] = 0xff;
|
||||||
|
bytes[1] = 0xd8;
|
||||||
|
bytes[2] = 0xff;
|
||||||
|
bytes[3] = 0xe0;
|
||||||
|
bytes[size - 2] = 0xff;
|
||||||
|
bytes[size - 1] = 0xd9;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
|
||||||
|
function countWrites(matcher, fn) {
|
||||||
|
const originalOpen = fs.openSync;
|
||||||
|
let writes = 0;
|
||||||
|
fs.openSync = function counting(target, flags, ...rest) {
|
||||||
|
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
|
||||||
|
return originalOpen.call(this, target, flags, ...rest);
|
||||||
|
};
|
||||||
|
try { fn(); } finally { fs.openSync = originalOpen; }
|
||||||
|
return writes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(s, id, question, answer) {
|
||||||
|
s.appendUser(id, { text: question, task: 'ask' });
|
||||||
|
const placeholder = s.appendAssistant(id, {});
|
||||||
|
return s.finishAssistant(id, placeholder.id, { text: answer });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 基本读写 ---
|
||||||
|
|
||||||
|
test('会话按 per-session 文件落盘,索引可枚举', () => {
|
||||||
|
const { sessions, dir } = fresh('basic');
|
||||||
|
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
|
||||||
|
const b = sessions.create({ entryId: 'e2' });
|
||||||
|
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
|
||||||
|
assert.strictEqual(a.entryId, 'e1');
|
||||||
|
assert.strictEqual(b.entryId, 'e2');
|
||||||
|
const files = fs.readdirSync(sessionDir(dir)).sort();
|
||||||
|
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
|
||||||
|
'每个会话应独占一个文件,加上一份索引');
|
||||||
|
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
|
||||||
|
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
|
||||||
|
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
|
||||||
|
const { sessions } = fresh('global');
|
||||||
|
const g = sessions.create({});
|
||||||
|
assert.strictEqual(g.entryId, 'system:global-chat');
|
||||||
|
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
|
||||||
|
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list 按 pinned desc, updatedAt desc 排序', () => {
|
||||||
|
const { sessions } = fresh('sort');
|
||||||
|
const a = sessions.create({ title: 'a' });
|
||||||
|
const b = sessions.create({ title: 'b' });
|
||||||
|
const c = sessions.create({ title: 'c' });
|
||||||
|
sessions.rename(b.id, 'b2');
|
||||||
|
sessions.setPinned(a.id, true);
|
||||||
|
const ids = sessions.list().map((row) => row.id);
|
||||||
|
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
|
||||||
|
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
|
||||||
|
const { sessions } = fresh('round');
|
||||||
|
const meta = sessions.create({ entryId: 'e1' });
|
||||||
|
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
|
||||||
|
const placeholder = sessions.appendAssistant(meta.id, {});
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
|
||||||
|
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
|
||||||
|
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
|
||||||
|
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
|
||||||
|
const read = sessions.messages(meta.id);
|
||||||
|
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
|
||||||
|
assert.strictEqual(read.messages[1].text, '这是回答');
|
||||||
|
assert.strictEqual(read.meta.messageCount, 2);
|
||||||
|
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
|
||||||
|
const { sessions } = fresh('finish');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: 'q' });
|
||||||
|
const p1 = sessions.appendAssistant(meta.id, {});
|
||||||
|
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
|
||||||
|
assert.strictEqual(cancelled.cancelled, true);
|
||||||
|
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
|
||||||
|
sessions.appendUser(meta.id, { text: 'q2' });
|
||||||
|
const p2 = sessions.appendAssistant(meta.id, {});
|
||||||
|
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
|
||||||
|
assert.strictEqual(failed.error, '接口超时');
|
||||||
|
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
|
||||||
|
const { sessions, dir } = fresh('lifecycle');
|
||||||
|
const meta = sessions.create({ entryId: 'e1', title: '保留' });
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const cleared = sessions.clear(meta.id);
|
||||||
|
assert.strictEqual(cleared.messageCount, 0);
|
||||||
|
assert.strictEqual(cleared.droppedMessages, 0);
|
||||||
|
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
|
||||||
|
assert.strictEqual(sessions.list().length, 1);
|
||||||
|
assert.strictEqual(sessions.remove(meta.id), true);
|
||||||
|
assert.strictEqual(sessions.remove(meta.id), false);
|
||||||
|
assert.strictEqual(sessions.list().length, 0);
|
||||||
|
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
|
||||||
|
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('messages 支持 limit 与 before 游标翻页', () => {
|
||||||
|
const { sessions } = fresh('paging');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < 5; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
const tail = sessions.messages(meta.id, { limit: 4 });
|
||||||
|
assert.strictEqual(tail.messages.length, 4);
|
||||||
|
assert.strictEqual(tail.hasMore, true);
|
||||||
|
assert.strictEqual(tail.messages[3].text, '答4');
|
||||||
|
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
|
||||||
|
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
|
||||||
|
'before 必须是排他的,否则翻页会重复一条');
|
||||||
|
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 字段限长 ---
|
||||||
|
|
||||||
|
test('受限字段超长时截断而不是抛错', () => {
|
||||||
|
const { sessions } = fresh('limits');
|
||||||
|
const L = sessions.LIMITS;
|
||||||
|
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
|
||||||
|
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
|
||||||
|
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
|
||||||
|
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
const done = sessions.finishAssistant(meta.id, p.id, {
|
||||||
|
text: '回答',
|
||||||
|
error: '错'.repeat(L.errorText + 100)
|
||||||
|
});
|
||||||
|
assert.strictEqual(done.text, '回答');
|
||||||
|
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
|
||||||
|
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
|
||||||
|
assert.strictEqual(renamed.title.length, L.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超过 20000 字的 assistant 正文经 append、finish 与磁盘恢复后保持完整', () => {
|
||||||
|
const { sessions, dir } = fresh('long-assistant');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: '请生成长回答' });
|
||||||
|
const initial = `占位开头${'初'.repeat(21000)}占位结尾`;
|
||||||
|
const placeholder = sessions.appendAssistant(meta.id, { text: initial });
|
||||||
|
assert.strictEqual(placeholder.text, initial, 'appendAssistant 不应静默截断 assistant 正文');
|
||||||
|
const answer = `回答开头${'答'.repeat(25000)}回答结尾`;
|
||||||
|
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: answer });
|
||||||
|
assert.strictEqual(done.text, answer, 'finishAssistant 不应静默截断 assistant 正文');
|
||||||
|
const raw = JSON.parse(fs.readFileSync(sessionFile(dir, meta.id), 'utf8'));
|
||||||
|
assert.strictEqual(raw.messages[1].text, answer, '超过 20000 字的回答必须完整落盘');
|
||||||
|
const restored = at(dir).sessions.messages(meta.id);
|
||||||
|
assert.strictEqual(restored.messages[1].text, answer, '超过 20000 字的回答必须从磁盘完整读回');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
|
||||||
|
const { sessions } = fresh('title');
|
||||||
|
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
|
||||||
|
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
|
||||||
|
const auto = sessions.create({});
|
||||||
|
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
|
||||||
|
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
|
||||||
|
const { sessions } = fresh('shape');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', contextRef: { scope: 'whatever' }
|
||||||
|
}), /会话上下文范围无效/);
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
|
||||||
|
const withContext = sessions.appendUser(meta.id, {
|
||||||
|
text: 'q',
|
||||||
|
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
|
||||||
|
});
|
||||||
|
assert.strictEqual(withContext.contextRef.chars, 4);
|
||||||
|
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
|
||||||
|
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
|
||||||
|
assert.strictEqual(withContext.contextRef.clipped, true);
|
||||||
|
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
|
||||||
|
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
|
||||||
|
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
|
||||||
|
}), /定位信息/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 上限策略 ---
|
||||||
|
|
||||||
|
test('会话数达上限抛中文错误,且不删除已有会话', () => {
|
||||||
|
const { sessions, dir } = fresh('total');
|
||||||
|
const total = sessions.LIMITS.sessionsTotal;
|
||||||
|
const ids = [];
|
||||||
|
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
|
||||||
|
const before = fs.readdirSync(sessionDir(dir)).length;
|
||||||
|
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
|
||||||
|
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
|
||||||
|
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
|
||||||
|
assert.strictEqual(sessions.list().length, total);
|
||||||
|
sessions.remove(ids[0]);
|
||||||
|
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
|
||||||
|
const { sessions } = fresh('evict');
|
||||||
|
const max = sessions.LIMITS.messagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
|
||||||
|
const first = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
|
||||||
|
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
const read = sessions.messages(meta.id, { limit: max });
|
||||||
|
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
|
||||||
|
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
|
||||||
|
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
|
||||||
|
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
|
||||||
|
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
|
||||||
|
assert.strictEqual(
|
||||||
|
read.meta.messageCount + read.meta.droppedMessages,
|
||||||
|
(max + 1) * 2,
|
||||||
|
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
|
||||||
|
);
|
||||||
|
const roles = read.messages.map((m) => m.role);
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||||
|
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
|
||||||
|
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
|
||||||
|
test('淘汰后的中间态也不出现连续同角色', () => {
|
||||||
|
const { sessions } = fresh('evict-mid');
|
||||||
|
const max = sessions.LIMITS.messagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
|
||||||
|
sessions.appendUser(meta.id, { text: '新问题' });
|
||||||
|
const read = sessions.messages(meta.id, { limit: max });
|
||||||
|
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
|
||||||
|
const roles = read.messages.map((m) => m.role);
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||||
|
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
|
||||||
|
}
|
||||||
|
assert.strictEqual(roles[0], 'user');
|
||||||
|
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
|
||||||
|
assert.strictEqual(roles[roles.length - 1], 'user');
|
||||||
|
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||||||
|
assert.strictEqual(
|
||||||
|
out.messages.length,
|
||||||
|
read.messages.length,
|
||||||
|
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
|
||||||
|
const { sessions, images } = fresh('img-evict');
|
||||||
|
const max = sessions.LIMITS.imagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const ids = [];
|
||||||
|
for (let i = 0; i <= max; i++) {
|
||||||
|
const put = images.put(jpeg(i + 1), 'image/jpeg');
|
||||||
|
ids.push(put.imageId);
|
||||||
|
sessions.appendUser(meta.id, {
|
||||||
|
text: `第 ${i} 张`,
|
||||||
|
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
|
||||||
|
});
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: `答 ${i}` });
|
||||||
|
}
|
||||||
|
const read = sessions.messages(meta.id, { limit: 500 });
|
||||||
|
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
|
||||||
|
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
|
||||||
|
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
|
||||||
|
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
|
||||||
|
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 文件隔离与写放大 ---
|
||||||
|
|
||||||
|
test('追加消息只重写自己那个会话文件', () => {
|
||||||
|
const { sessions, dir } = fresh('isolate');
|
||||||
|
const a = sessions.create({ title: 'a' });
|
||||||
|
const b = sessions.create({ title: 'b' });
|
||||||
|
const bFile = sessionFile(dir, b.id);
|
||||||
|
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||||||
|
round(sessions, a.id, '问', '答');
|
||||||
|
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||||||
|
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
|
||||||
|
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
|
||||||
|
round(sessions, a.id, '问2', '答2');
|
||||||
|
});
|
||||||
|
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
|
||||||
|
const { sessions, dir } = fresh('writes');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const isSession = (t) => t === `${file}.tmp`;
|
||||||
|
const oneRound = countWrites(isSession, () => {
|
||||||
|
sessions.appendUser(meta.id, { text: '问' });
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
|
||||||
|
});
|
||||||
|
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
|
||||||
|
// 对照:两轮就是四次,证明计数器没失灵
|
||||||
|
const twoRounds = countWrites(isSession, () => {
|
||||||
|
round(sessions, meta.id, '问2', '答2');
|
||||||
|
round(sessions, meta.id, '问3', '答3');
|
||||||
|
});
|
||||||
|
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
|
||||||
|
round(sessions, meta.id, '问4', '答4');
|
||||||
|
});
|
||||||
|
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
|
||||||
|
const { sessions, dir } = fresh('cache');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '原始问题', '原始回答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
|
||||||
|
const fixed = new Date(1700000000000);
|
||||||
|
fs.utimesSync(file, fixed, fixed);
|
||||||
|
const stat = fs.statSync(file);
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
|
||||||
|
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
|
||||||
|
raw.messages[0].text = '篡改问题';
|
||||||
|
const rewritten = JSON.stringify(raw, null, 2);
|
||||||
|
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
|
||||||
|
fs.writeFileSync(file, rewritten);
|
||||||
|
fs.utimesSync(file, fixed, fixed);
|
||||||
|
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
|
||||||
|
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
|
||||||
|
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 损坏自愈 ---
|
||||||
|
|
||||||
|
test('主文件损坏时从 .bak 恢复', () => {
|
||||||
|
const { sessions, dir } = fresh('bak');
|
||||||
|
const meta = sessions.create({ entryId: 'e1' });
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
fs.copyFileSync(file, `${file}.bak`);
|
||||||
|
fs.writeFileSync(file, '{ 这不是 JSON');
|
||||||
|
const read = sessions.messages(meta.id);
|
||||||
|
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
|
||||||
|
assert.strictEqual(read.messages[0].text, '问');
|
||||||
|
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||||||
|
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
|
||||||
|
const { sessions, dir } = fresh('corrupt');
|
||||||
|
const meta = sessions.create({ entryId: 'e1', title: '标题' });
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
fs.writeFileSync(file, 'broken');
|
||||||
|
fs.writeFileSync(`${file}.bak`, 'also broken');
|
||||||
|
const read = sessions.messages(meta.id);
|
||||||
|
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
|
||||||
|
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
|
||||||
|
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||||||
|
assert.strictEqual(corrupt.length, 1);
|
||||||
|
round(sessions, meta.id, '新问', '新答');
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index.json 丢失后扫目录重建,list 结果正确', () => {
|
||||||
|
const { sessions, dir } = fresh('reindex');
|
||||||
|
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||||||
|
const b = sessions.create({ entryId: 'e2', title: '乙' });
|
||||||
|
round(sessions, a.id, '问', '答');
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
fs.unlinkSync(idxFile);
|
||||||
|
const reloaded = at(dir).sessions;
|
||||||
|
const rows = reloaded.list();
|
||||||
|
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
|
||||||
|
const rowA = rows.find((r) => r.id === a.id);
|
||||||
|
assert.strictEqual(rowA.title, '甲');
|
||||||
|
assert.strictEqual(rowA.entryId, 'e1');
|
||||||
|
assert.strictEqual(rowA.messageCount, 2);
|
||||||
|
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
|
||||||
|
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
|
||||||
|
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
|
||||||
|
const { sessions, dir } = fresh('reindex2');
|
||||||
|
const a = sessions.create({ title: '甲' });
|
||||||
|
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
|
||||||
|
const reloaded = at(dir).sessions;
|
||||||
|
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
|
||||||
|
const { sessions, dir } = fresh('reindex3');
|
||||||
|
const a = sessions.create({ title: '甲' });
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||||||
|
idx.sessions.push({ id: '../escape', title: '恶意' });
|
||||||
|
idx.sessions.push({ id: '__proto__', title: '污染' });
|
||||||
|
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||||||
|
const rows = at(dir).sessions.list();
|
||||||
|
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
|
||||||
|
const { sessions, dir } = fresh('bad-msg');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '好问题', '好回答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
|
||||||
|
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||||
|
const read = at(dir).sessions.messages(meta.id);
|
||||||
|
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
|
||||||
|
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
|
||||||
|
assert.deepStrictEqual(read.messages[2].images, []);
|
||||||
|
assert.strictEqual(read.messages[2].task, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ID 校验 ---
|
||||||
|
|
||||||
|
test('非法会话 ID 被拒,不产生任何文件', () => {
|
||||||
|
const { sessions, dir } = fresh('ids');
|
||||||
|
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
|
||||||
|
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
|
||||||
|
for (const id of bad) {
|
||||||
|
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
|
||||||
|
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
|
||||||
|
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
|
||||||
|
}
|
||||||
|
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
|
||||||
|
'被拒的 ID 不应在磁盘上留下文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法 entryId 与 messageId 被拒', () => {
|
||||||
|
const { sessions } = fresh('ids2');
|
||||||
|
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
|
||||||
|
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
|
||||||
|
}
|
||||||
|
const meta = sessions.create({ entryId: 'e1' });
|
||||||
|
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||||||
|
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
|
||||||
|
}
|
||||||
|
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||||||
|
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
|
||||||
|
}
|
||||||
|
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finishAssistant 不能跨会话认领占位消息', () => {
|
||||||
|
const { sessions } = fresh('cross');
|
||||||
|
const a = sessions.create({});
|
||||||
|
const b = sessions.create({});
|
||||||
|
sessions.appendUser(a.id, { text: 'q' });
|
||||||
|
const p = sessions.appendAssistant(a.id, {});
|
||||||
|
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
|
||||||
|
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- historyFor ---
|
||||||
|
|
||||||
|
function history(sessions, id, budget) {
|
||||||
|
return sessions.historyFor(id, budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('historyFor 始终 pin 首条 user 消息', () => {
|
||||||
|
const { sessions } = fresh('hist-pin');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
|
||||||
|
for (let i = 0; i < 20; i++) round(sessions, meta.id, `问${i}`.repeat(20), `答${i}`.repeat(20));
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
|
||||||
|
assert.strictEqual(out.messages[0].role, 'user');
|
||||||
|
assert.ok(out.messages[0].text.includes('正文'),
|
||||||
|
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
|
||||||
|
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
|
||||||
|
assert.ok(out.dropped > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
|
||||||
|
const { sessions } = fresh('hist-mark');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
|
||||||
|
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
|
||||||
|
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
|
||||||
|
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
|
||||||
|
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
|
||||||
|
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
|
||||||
|
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
|
||||||
|
'不允许为了放标记而造出 system 之类的新角色');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 总字符不超过 maxChars', () => {
|
||||||
|
const { sessions } = fresh('hist-total');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < 15; i++) round(sessions, meta.id, `问${i}`.repeat(50), `答${i}`.repeat(50));
|
||||||
|
for (const maxChars of [200, 500, 1200, 3000]) {
|
||||||
|
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
|
||||||
|
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
|
||||||
|
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 单条超限中间挖空且保留尾部', () => {
|
||||||
|
const { sessions } = fresh('hist-clip');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const head = '开头标记';
|
||||||
|
const tail = '结尾标记';
|
||||||
|
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: '答' });
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
|
||||||
|
const first = out.messages[0];
|
||||||
|
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
|
||||||
|
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
|
||||||
|
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
|
||||||
|
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
|
||||||
|
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
|
||||||
|
const { sessions, dir } = fresh('hist-lead');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
raw.messages = [
|
||||||
|
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
|
||||||
|
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
|
||||||
|
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
|
||||||
|
];
|
||||||
|
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||||
|
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
|
||||||
|
assert.strictEqual(out.messages[0].role, 'user',
|
||||||
|
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
|
||||||
|
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 不产生连续同角色消息', () => {
|
||||||
|
const { sessions, dir } = fresh('hist-roles');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
raw.messages = [
|
||||||
|
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
|
||||||
|
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
|
||||||
|
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
|
||||||
|
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
|
||||||
|
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
|
||||||
|
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
|
||||||
|
];
|
||||||
|
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||||
|
const reloaded = at(dir).sessions;
|
||||||
|
for (const budget of [
|
||||||
|
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
|
||||||
|
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
|
||||||
|
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
|
||||||
|
]) {
|
||||||
|
const out = reloaded.historyFor(meta.id, budget);
|
||||||
|
const roles = out.messages.map((m) => m.role);
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||||
|
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
|
||||||
|
}
|
||||||
|
if (roles.length) assert.strictEqual(roles[0], 'user');
|
||||||
|
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
|
||||||
|
const { sessions } = fresh('hist-dropped');
|
||||||
|
const max = sessions.LIMITS.messagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
const read = sessions.messages(meta.id, { limit: max });
|
||||||
|
assert.ok(read.meta.droppedMessages > 0);
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||||||
|
assert.ok(out.dropped >= read.meta.droppedMessages,
|
||||||
|
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
|
||||||
|
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 在空会话与单条会话上不炸', () => {
|
||||||
|
const { sessions } = fresh('hist-edge');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
|
||||||
|
sessions.appendUser(meta.id, { text: '只有一条' });
|
||||||
|
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
|
||||||
|
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
|
||||||
|
assert.strictEqual(out.messages[0].role, 'user');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- imageIds 与 GC ---
|
||||||
|
|
||||||
|
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
|
||||||
|
const { sessions, images, dir } = fresh('gc-scan');
|
||||||
|
const a = sessions.create({ title: '甲' });
|
||||||
|
const b = sessions.create({ title: '乙' });
|
||||||
|
const imgA = images.put(jpeg(11), 'image/jpeg');
|
||||||
|
const imgB = images.put(jpeg(22), 'image/jpeg');
|
||||||
|
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
|
||||||
|
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
|
||||||
|
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||||||
|
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
|
||||||
|
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||||||
|
const reloaded = at(dir);
|
||||||
|
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
|
||||||
|
const ids = reloaded.sessions.imageIds();
|
||||||
|
assert.ok(ids.includes(imgB.imageId),
|
||||||
|
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
|
||||||
|
assert.ok(ids.includes(imgA.imageId));
|
||||||
|
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
|
||||||
|
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
|
||||||
|
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
|
||||||
|
const { sessions, images, dir } = fresh('gc-bak');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const img = images.put(jpeg(33), 'image/jpeg');
|
||||||
|
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
fs.copyFileSync(file, `${file}.bak`);
|
||||||
|
fs.writeFileSync(file, 'broken');
|
||||||
|
const reloaded = at(dir);
|
||||||
|
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
|
||||||
|
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
|
||||||
|
const pendingImg = images.put(jpeg(44), 'image/jpeg');
|
||||||
|
const s2 = fresh('gc-pending');
|
||||||
|
const m2 = s2.sessions.create({});
|
||||||
|
s2.sessions.appendUser(m2.id, { text: 'q' });
|
||||||
|
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
|
||||||
|
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
|
||||||
|
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话删除后其图像被 cleanup 回收', () => {
|
||||||
|
const { sessions, images, dir } = fresh('gc-remove');
|
||||||
|
const keep = sessions.create({ title: '留' });
|
||||||
|
const drop = sessions.create({ title: '删' });
|
||||||
|
const imgKeep = images.put(jpeg(55), 'image/jpeg');
|
||||||
|
const imgDrop = images.put(jpeg(66), 'image/jpeg');
|
||||||
|
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
|
||||||
|
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
|
||||||
|
sessions.remove(drop.id);
|
||||||
|
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
|
||||||
|
assert.strictEqual(removed, 1);
|
||||||
|
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
|
||||||
|
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ai-images ---
|
||||||
|
|
||||||
|
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
|
||||||
|
const { images } = fresh('img-mime');
|
||||||
|
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||||
|
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
|
||||||
|
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
|
||||||
|
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
|
||||||
|
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
|
||||||
|
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
|
||||||
|
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
|
||||||
|
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
|
||||||
|
const { images, dir } = fresh('img-dedup');
|
||||||
|
const bytes = jpeg(77, 1024);
|
||||||
|
const first = images.put(bytes, 'image/jpeg');
|
||||||
|
const second = images.put(Buffer.from(bytes), 'image/jpeg');
|
||||||
|
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
|
||||||
|
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
|
||||||
|
assert.strictEqual(images.totalBytes(), 1024);
|
||||||
|
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
|
||||||
|
const other = images.put(jpeg(78, 1024), 'image/jpeg');
|
||||||
|
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
|
||||||
|
assert.strictEqual(images.totalBytes(), 2048);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
|
||||||
|
const { images } = fresh('img-read');
|
||||||
|
const bytes = jpeg(88, 256);
|
||||||
|
const { imageId } = images.put(bytes, 'image/jpeg');
|
||||||
|
assert.ok(images.read(imageId).equals(bytes));
|
||||||
|
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
|
||||||
|
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
|
||||||
|
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
|
||||||
|
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
|
||||||
|
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
|
||||||
|
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
|
||||||
|
}
|
||||||
|
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
|
||||||
|
const { images, dir } = fresh('img-cleanup');
|
||||||
|
const a = images.put(jpeg(1, 128), 'image/jpeg');
|
||||||
|
const b = images.put(jpeg(2, 128), 'image/jpeg');
|
||||||
|
const c = images.put(jpeg(3, 128), 'image/jpeg');
|
||||||
|
assert.strictEqual(images.cleanup([a.imageId]), 0,
|
||||||
|
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
|
||||||
|
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
|
||||||
|
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
|
||||||
|
assert.ok(images.read(a.imageId));
|
||||||
|
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
|
||||||
|
assert.strictEqual(images.totalBytes(), 0);
|
||||||
|
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
|
||||||
|
// 白名单里的垃圾值不应意外保住任何文件
|
||||||
|
images.put(jpeg(4, 128), 'image/jpeg');
|
||||||
|
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
|
||||||
|
const { images } = fresh('img-empty');
|
||||||
|
assert.strictEqual(images.totalBytes(), 0);
|
||||||
|
assert.strictEqual(images.cleanup([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话里的图像元数据被规范化,非法引用被拒', () => {
|
||||||
|
const { sessions, images } = fresh('img-meta');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const img = images.put(jpeg(99, 512), 'image/jpeg');
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', images: [{ imageId: '../escape' }]
|
||||||
|
}), /会话图像标识无效/);
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
|
||||||
|
}), /仅支持 JPEG/);
|
||||||
|
const stored = sessions.appendUser(meta.id, {
|
||||||
|
text: 'q',
|
||||||
|
images: [
|
||||||
|
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
|
||||||
|
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
|
||||||
|
assert.deepStrictEqual(stored.images[0], {
|
||||||
|
imageId: img.imageId,
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
width: 100,
|
||||||
|
height: 200,
|
||||||
|
bytes: 512,
|
||||||
|
ocrIncluded: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 对账 ---
|
||||||
|
|
||||||
|
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
|
||||||
|
const { sessions } = fresh('orphan');
|
||||||
|
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
|
||||||
|
const gone = sessions.create({ entryId: 'e404', title: '已删' });
|
||||||
|
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||||||
|
round(sessions, gone.id, '问', '答');
|
||||||
|
const report = sessions.orphanReport(['e1']);
|
||||||
|
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
|
||||||
|
assert.strictEqual(report[0].entryId, 'e404');
|
||||||
|
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
|
||||||
|
assert.strictEqual(report[0].messageCount, 2);
|
||||||
|
assert.ok(report[0].bytes > 0);
|
||||||
|
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
|
||||||
|
assert.ok(!report.some((r) => r.sessionId === inLib.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
|
||||||
|
const { sessions } = fresh('forget');
|
||||||
|
const keep = sessions.create({ entryId: 'e1', title: '在库' });
|
||||||
|
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
|
||||||
|
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
|
||||||
|
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||||||
|
assert.strictEqual(sessions.forgetMany([]), 0);
|
||||||
|
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
|
||||||
|
const remaining = sessions.list().map((row) => row.id).sort();
|
||||||
|
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
|
||||||
|
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
|
||||||
|
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
|
||||||
|
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rebuildIndex 返回索引形状并与 list 一致', () => {
|
||||||
|
const { sessions } = fresh('rebuild');
|
||||||
|
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||||||
|
round(sessions, a.id, '问', '答');
|
||||||
|
const idx = sessions.rebuildIndex();
|
||||||
|
assert.strictEqual(idx.version, 1);
|
||||||
|
assert.strictEqual(idx.sessions.length, 1);
|
||||||
|
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
|
||||||
|
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
|
||||||
|
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list 返回的是副本,改动不会污染内存索引', () => {
|
||||||
|
const { sessions } = fresh('clone');
|
||||||
|
const meta = sessions.create({ title: '原标题' });
|
||||||
|
const rows = sessions.list();
|
||||||
|
rows[0].title = '被改坏';
|
||||||
|
assert.strictEqual(sessions.list()[0].title, '原标题');
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
|
||||||
|
});
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const h = require('./helpers');
|
||||||
|
|
||||||
|
h.installFetchStub();
|
||||||
|
|
||||||
|
const cfgPath = require.resolve('../reader/ai-config.js');
|
||||||
|
const clientPath = require.resolve('../reader/ai-client.js');
|
||||||
|
|
||||||
|
const dirs = [];
|
||||||
|
function tmp() {
|
||||||
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-ai-'));
|
||||||
|
dirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
test.after(() => {
|
||||||
|
for (const d of dirs) {
|
||||||
|
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString: (s) => Buffer.from('E' + s),
|
||||||
|
decryptString: (b) => b.toString().slice(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup({
|
||||||
|
protocol = 'chat-completions',
|
||||||
|
baseUrl = 'https://api.test.com/v1',
|
||||||
|
model = 'm',
|
||||||
|
apiKey = 'sk-1',
|
||||||
|
vision = false
|
||||||
|
} = {}) {
|
||||||
|
delete require.cache[cfgPath];
|
||||||
|
delete require.cache[clientPath];
|
||||||
|
const cfg = require(cfgPath);
|
||||||
|
cfg.init(tmp(), storage);
|
||||||
|
cfg.save({ protocol, baseUrl, model, apiKey, vision });
|
||||||
|
return require(clientPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualContext(overrides = {}) {
|
||||||
|
const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||||
|
return {
|
||||||
|
kind: 'page',
|
||||||
|
image: {
|
||||||
|
mimeType: 'image/png',
|
||||||
|
base64,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
bytes: Buffer.from(base64, 'base64').length
|
||||||
|
},
|
||||||
|
ocr: { status: 'idle', text: '', include: false },
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sseBody(chunks, { done = true } = {}) {
|
||||||
|
const lines = chunks.map((c) => `data: ${JSON.stringify({ choices: [{ delta: { content: c } }] })}\n\n`);
|
||||||
|
if (done) lines.push('data: [DONE]\n\n');
|
||||||
|
return lines.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把字符串切成多个 chunk,模拟真实网络分片(含跨 chunk 断行)
|
||||||
|
function streamResponse(text, { status = 200, pieces = 3 } = {}) {
|
||||||
|
const buf = Buffer.from(text, 'utf8');
|
||||||
|
const size = Math.ceil(buf.length / pieces);
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < buf.length; i += size) parts.push(buf.subarray(i, i + size));
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
headers: { get: () => null, getSetCookie: () => [] },
|
||||||
|
text: async () => text,
|
||||||
|
json: async () => JSON.parse(text),
|
||||||
|
body: (async function* () { for (const p of parts) yield p; })()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('流式增量按顺序回调并拼出完整文本', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
h.setHandler(() => streamResponse(sseBody(['你', '好', '世界']), { pieces: 5 }));
|
||||||
|
const seen = [];
|
||||||
|
const full = await ai.stream({ task: 'translate', text: 'hello', onDelta: (d) => seen.push(d) });
|
||||||
|
assert.strictEqual(full, '你好世界');
|
||||||
|
assert.deepStrictEqual(seen, ['你', '好', '世界']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SSE 分片跨 chunk 断开也能正确解析', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
// 每个字节一个 chunk,保证 data: 行被切碎
|
||||||
|
h.setHandler(() => streamResponse(sseBody(['abc', 'def']), { pieces: 200 }));
|
||||||
|
const full = await ai.stream({ task: 'explain', text: 'x' });
|
||||||
|
assert.strictEqual(full, 'abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('遇到 [DONE] 立即结束,不解析后续内容', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
const body = sseBody(['一'], { done: true }) + sseBody(['不该出现'], { done: false });
|
||||||
|
h.setHandler(() => streamResponse(body));
|
||||||
|
assert.strictEqual(await ai.stream({ task: 'summarize', text: 'x' }), '一');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 错误体里的 message 会被提取为中文可读错误', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: 'model not found' } }), { status: 404 }));
|
||||||
|
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /model not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('401 无 JSON 体时给出可读提示', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
h.setHandler(() => streamResponse('Unauthorized', { status: 401 }));
|
||||||
|
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key 无效/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流内返回 error 字段也会抛出', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
h.setHandler(() => streamResponse('data: ' + JSON.stringify({ error: { message: '额度不足' } }) + '\n\n'));
|
||||||
|
await assert.rejects(() => ai.stream({ task: 'ask', text: 'x', question: 'q' }), /额度不足/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未配置 Key 且非本地端点时拒绝请求', async () => {
|
||||||
|
const ai = setup({ apiKey: '' });
|
||||||
|
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本地端点无 Key 也允许请求,且不带 Authorization 头', async () => {
|
||||||
|
const ai = setup({ baseUrl: 'http://localhost:11434/v1', apiKey: '' });
|
||||||
|
let seenHeaders = null;
|
||||||
|
h.setHandler((_u, o) => { seenHeaders = o.headers; return streamResponse(sseBody(['ok'])); });
|
||||||
|
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
|
||||||
|
assert.ok(!seenHeaders.Authorization, '本地模型不该发送 Authorization');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('请求体包含模型名与 stream 标志,且 Key 放在头里', async () => {
|
||||||
|
const ai = setup({ model: 'deepseek-chat', apiKey: 'sk-abc' });
|
||||||
|
let seen = null;
|
||||||
|
h.setHandler((u, o) => { seen = { u, o }; return streamResponse(sseBody(['x'])); });
|
||||||
|
await ai.stream({ task: 'translate', text: 'hi' });
|
||||||
|
const body = JSON.parse(seen.o.body);
|
||||||
|
assert.strictEqual(body.model, 'deepseek-chat');
|
||||||
|
assert.strictEqual(body.stream, true);
|
||||||
|
assert.strictEqual(seen.o.headers.Authorization, 'Bearer sk-abc');
|
||||||
|
assert.ok(seen.u.endsWith('/chat/completions'), '端点拼接错误: ' + seen.u);
|
||||||
|
assert.ok(!seen.u.includes('sk-abc'), 'Key 不该出现在 URL 中');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('启用图像输入后使用 OpenAI 兼容的 image_url 消息', async () => {
|
||||||
|
const ai = setup({ vision: true });
|
||||||
|
let body = null;
|
||||||
|
h.setHandler((_u, options) => {
|
||||||
|
body = JSON.parse(options.body);
|
||||||
|
return streamResponse(sseBody(['看到了']));
|
||||||
|
});
|
||||||
|
const full = await ai.stream({
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '图中是什么?',
|
||||||
|
visualContexts: [visualContext()]
|
||||||
|
});
|
||||||
|
assert.strictEqual(full, '看到了');
|
||||||
|
assert.ok(Array.isArray(body.messages[1].content));
|
||||||
|
assert.strictEqual(body.messages[1].content[0].type, 'text');
|
||||||
|
assert.strictEqual(body.messages[1].content[1].type, 'image_url');
|
||||||
|
assert.match(body.messages[1].content[1].image_url.url, /^data:image\/png;base64,/);
|
||||||
|
assert.deepStrictEqual(Object.keys(body.messages[1].content[1].image_url), ['url']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', async () => {
|
||||||
|
const ai = setup({ protocol: 'anthropic', vision: true });
|
||||||
|
let seen = null;
|
||||||
|
h.setHandler((url, options) => {
|
||||||
|
seen = { url, options, body: JSON.parse(options.body) };
|
||||||
|
return streamResponse([
|
||||||
|
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '识别' } })}\n\n`,
|
||||||
|
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '成功' } })}\n\n`,
|
||||||
|
`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`
|
||||||
|
].join(''), { pieces: 11 });
|
||||||
|
});
|
||||||
|
const full = await ai.stream({
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '图中是什么?',
|
||||||
|
visualContexts: [visualContext()]
|
||||||
|
});
|
||||||
|
assert.strictEqual(full, '识别成功');
|
||||||
|
assert.ok(seen.url.endsWith('/messages'), seen.url);
|
||||||
|
assert.strictEqual(seen.options.headers['x-api-key'], 'sk-1');
|
||||||
|
assert.strictEqual(seen.options.headers['anthropic-version'], '2023-06-01');
|
||||||
|
assert.ok(!seen.options.headers.Authorization);
|
||||||
|
assert.strictEqual(seen.body.system.includes('文档页面图像'), true);
|
||||||
|
assert.strictEqual(seen.body.messages.length, 1);
|
||||||
|
assert.strictEqual(seen.body.messages[0].content[0].type, 'text');
|
||||||
|
const image = seen.body.messages[0].content[1];
|
||||||
|
assert.strictEqual(image.type, 'image');
|
||||||
|
assert.deepStrictEqual(Object.keys(image.source), ['type', 'media_type', 'data']);
|
||||||
|
assert.strictEqual(image.source.type, 'base64');
|
||||||
|
assert.strictEqual(image.source.media_type, 'image/png');
|
||||||
|
assert.ok(image.source.data.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenAI Responses 接口使用 input_image 和响应增量事件', async () => {
|
||||||
|
const ai = setup({ protocol: 'openai-responses', vision: true });
|
||||||
|
let seen = null;
|
||||||
|
h.setHandler((url, options) => {
|
||||||
|
seen = { url, options, body: JSON.parse(options.body) };
|
||||||
|
return streamResponse([
|
||||||
|
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '看见' })}\n\n`,
|
||||||
|
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '图片' })}\n\n`,
|
||||||
|
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`
|
||||||
|
].join(''), { pieces: 13 });
|
||||||
|
});
|
||||||
|
const full = await ai.stream({
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '图中是什么?',
|
||||||
|
visualContexts: [visualContext()]
|
||||||
|
});
|
||||||
|
assert.strictEqual(full, '看见图片');
|
||||||
|
assert.ok(seen.url.endsWith('/responses'), seen.url);
|
||||||
|
assert.strictEqual(seen.options.headers.Authorization, 'Bearer sk-1');
|
||||||
|
assert.strictEqual(seen.body.instructions.includes('文档页面图像'), true);
|
||||||
|
assert.strictEqual(seen.body.max_output_tokens, 1024);
|
||||||
|
assert.strictEqual(seen.body.store, false);
|
||||||
|
assert.strictEqual(seen.body.input[0].content[0].type, 'input_text');
|
||||||
|
const image = seen.body.input[0].content[1];
|
||||||
|
assert.strictEqual(image.type, 'input_image');
|
||||||
|
assert.match(image.image_url, /^data:image\/png;base64,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenAI Responses 失败事件不会被当作空回答', async () => {
|
||||||
|
const ai = setup({ protocol: 'openai-responses' });
|
||||||
|
h.setHandler(() => streamResponse(
|
||||||
|
`event: response.failed\ndata: ${JSON.stringify({
|
||||||
|
type: 'response.failed',
|
||||||
|
response: { error: { message: 'responses failed' } }
|
||||||
|
})}\n\n`
|
||||||
|
));
|
||||||
|
await assert.rejects(
|
||||||
|
() => ai.stream({ task: 'translate', text: 'x' }),
|
||||||
|
/responses failed/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('协议端点追加在查询参数之前并保留参数', async () => {
|
||||||
|
const ai = setup({
|
||||||
|
protocol: 'openai-responses',
|
||||||
|
baseUrl: 'https://gateway.example.com/v1?api-version=2026-01-01'
|
||||||
|
});
|
||||||
|
let requestUrl = '';
|
||||||
|
h.setHandler((url) => {
|
||||||
|
requestUrl = url;
|
||||||
|
return streamResponse(
|
||||||
|
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'ok' })}\n\n`
|
||||||
|
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
|
||||||
|
const url = new URL(requestUrl);
|
||||||
|
assert.strictEqual(url.pathname, '/v1/responses');
|
||||||
|
assert.strictEqual(url.searchParams.get('api-version'), '2026-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未显式启用图像能力时拒绝发送图片', async () => {
|
||||||
|
const ai = setup({ vision: false });
|
||||||
|
await assert.rejects(
|
||||||
|
() => ai.stream({
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '图中是什么?',
|
||||||
|
visualContexts: [visualContext()]
|
||||||
|
}),
|
||||||
|
/未启用图像输入/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图像上下文拒绝伪造尺寸、远程地址和多图输入', () => {
|
||||||
|
const ai = setup({ vision: true });
|
||||||
|
const badSize = visualContext();
|
||||||
|
badSize.image.width = 2;
|
||||||
|
assert.throws(() => ai.buildMessages('ask', '', 'q', [badSize]), /声明尺寸不匹配/);
|
||||||
|
assert.throws(
|
||||||
|
() => ai.buildMessages('ask', '', 'q', [{ kind: 'page', image: { url: 'https://example.com/a.png' } }]),
|
||||||
|
/JPEG 或 PNG/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => ai.buildMessages('ask', '', 'q', [visualContext(), visualContext()]),
|
||||||
|
/最多发送 1 张/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OCR 预留契约仅在识别完成且勾选后附加文字', () => {
|
||||||
|
const ai = setup({ vision: true });
|
||||||
|
const context = visualContext({
|
||||||
|
ocr: { status: 'ready', text: '校对后的 OCR 内容', include: true }
|
||||||
|
});
|
||||||
|
const messages = ai.buildMessages('ask', '', '这是什么?', [context]);
|
||||||
|
const textPart = messages[1].content.find((part) => part.type === 'text');
|
||||||
|
assert.match(textPart.text, /OCR 识别文字/);
|
||||||
|
assert.match(textPart.text, /校对后的 OCR 内容/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
|
||||||
|
const ai = setup({ vision: false });
|
||||||
|
let body = null;
|
||||||
|
h.setHandler((_url, options) => {
|
||||||
|
body = JSON.parse(options.body);
|
||||||
|
return streamResponse(sseBody(['文字回答']));
|
||||||
|
});
|
||||||
|
const context = visualContext({
|
||||||
|
includeImage: false,
|
||||||
|
ocr: { status: 'ready', text: '仅发送 OCR', include: true }
|
||||||
|
});
|
||||||
|
assert.strictEqual(await ai.stream({
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '内容是什么?',
|
||||||
|
visualContexts: [context]
|
||||||
|
}), '文字回答');
|
||||||
|
assert.strictEqual(typeof body.messages[1].content, 'string');
|
||||||
|
assert.match(body.messages[1].content, /仅发送 OCR/);
|
||||||
|
assert.doesNotMatch(body.messages[1].content, /data:image/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('显式调用 clipContext 时中间挖空并保留首尾', () => {
|
||||||
|
const ai = setup();
|
||||||
|
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
|
||||||
|
const clipped = ai.clipContext(long, 2000);
|
||||||
|
assert.ok(clipped.length < long.length);
|
||||||
|
assert.ok(clipped.startsWith('A'), '开头丢失');
|
||||||
|
assert.ok(clipped.includes('TAIL_MARK'), '结尾丢失了,结论性内容会被切掉');
|
||||||
|
assert.ok(clipped.includes('省略'), '未标注截断');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('正文完整外发,不再按 MAX_CHARS 静默截断', () => {
|
||||||
|
const ai = setup();
|
||||||
|
// 40 页文档实测:旧行为只发出 8 页,其余 32 页静默丢失,而界面仍显示全文字数
|
||||||
|
const body = 'X'.repeat(ai.MAX_CHARS * 4) + 'TAIL_MARK';
|
||||||
|
const msgs = ai.buildMessages('ask', body, '这讲了什么');
|
||||||
|
assert.ok(msgs[1].content.includes(body), '正文被截断了');
|
||||||
|
assert.ok(!msgs[1].content.includes('中间省略'), '不应再自动挖空正文');
|
||||||
|
assert.ok(msgs[1].content.includes('TAIL_MARK'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('接口报的上下文超限被转成可操作的中文提示', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
for (const [status, raw] of [
|
||||||
|
[400, "This model's maximum context length is 8192 tokens, however you requested 90000 tokens"],
|
||||||
|
[400, 'prompt is too long: 250000 tokens > 200000 maximum'],
|
||||||
|
[413, 'Payload Too Large']
|
||||||
|
]) {
|
||||||
|
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: raw } }), { status }));
|
||||||
|
await assert.rejects(
|
||||||
|
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /上下文超出模型窗口/);
|
||||||
|
assert.match(err.message, /范围改小|更大窗口/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
`HTTP ${status} 未被识别为上下文超限`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通错误不应被误判成超限
|
||||||
|
h.setHandler(() => streamResponse(
|
||||||
|
JSON.stringify({ error: { message: 'invalid temperature value' } }),
|
||||||
|
{ status: 400 }
|
||||||
|
));
|
||||||
|
await assert.rejects(
|
||||||
|
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
|
||||||
|
(err) => {
|
||||||
|
assert.doesNotMatch(err.message, /上下文超出模型窗口/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不支持的任务类型被拒绝', () => {
|
||||||
|
const ai = setup();
|
||||||
|
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ask 任务把问题与片段一起送出', () => {
|
||||||
|
const ai = setup();
|
||||||
|
const msgs = ai.buildMessages('ask', '文档内容', '这讲了什么');
|
||||||
|
assert.strictEqual(msgs.length, 2);
|
||||||
|
assert.ok(msgs[1].content.includes('文档内容'));
|
||||||
|
assert.ok(msgs[1].content.includes('这讲了什么'));
|
||||||
|
assert.ok(/编造|没有提到/.test(msgs[0].content), '缺少防幻觉约束');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取消请求时抛出 AbortError 而不是静默返回', async () => {
|
||||||
|
const ai = setup();
|
||||||
|
const ctl = new AbortController();
|
||||||
|
h.setHandler(() => { ctl.abort(); return streamResponse(sseBody(['x'])); });
|
||||||
|
await assert.rejects(
|
||||||
|
() => ai.stream({ task: 'translate', text: 'x', signal: ctl.signal }),
|
||||||
|
(e) => e.name === 'AbortError'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 多轮对话历史
|
||||||
|
const PROTOCOL_STREAMS = {
|
||||||
|
'chat-completions': () => streamResponse(sseBody(['答'])),
|
||||||
|
anthropic: () => streamResponse(
|
||||||
|
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '答' } })}\n\n`
|
||||||
|
+ `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`
|
||||||
|
),
|
||||||
|
'openai-responses': () => streamResponse(
|
||||||
|
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: '答' })}\n\n`
|
||||||
|
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
async function captureBody(protocol, args, options) {
|
||||||
|
const ai = setup({ protocol, ...options });
|
||||||
|
let body = null;
|
||||||
|
h.setHandler((_url, opts) => {
|
||||||
|
body = JSON.parse(opts.body);
|
||||||
|
return PROTOCOL_STREAMS[protocol]();
|
||||||
|
});
|
||||||
|
await ai.stream(args);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
|
||||||
|
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
|
||||||
|
// 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染
|
||||||
|
const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题';
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
|
||||||
|
if (protocol === 'chat-completions') {
|
||||||
|
assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user');
|
||||||
|
assert.strictEqual(base.messages[1].content, expectedUser);
|
||||||
|
} else if (protocol === 'anthropic') {
|
||||||
|
assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user');
|
||||||
|
assert.strictEqual(base.messages[0].content, expectedUser);
|
||||||
|
} else {
|
||||||
|
assert.strictEqual(base.input.length, 1, '单轮只该有一条 user');
|
||||||
|
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]);
|
||||||
|
}
|
||||||
|
for (const history of [undefined, null, [], 'not-an-array', {}]) {
|
||||||
|
const withArg = await captureBody(
|
||||||
|
protocol,
|
||||||
|
{ task: 'ask', text: '正文', question: '问题', history }
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
JSON.stringify(withArg),
|
||||||
|
JSON.stringify(base),
|
||||||
|
`${protocol} 的空 history 改变了请求体(history=${JSON.stringify(history)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chat-completions 把历史插在 system 之后、当前轮之前', async () => {
|
||||||
|
const body = await captureBody('chat-completions', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '第三个问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '第一个问题' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '第一个回答' },
|
||||||
|
{ id: 'c', role: 'user', text: '第二个问题' },
|
||||||
|
{ id: 'd', role: 'assistant', text: '第二个回答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
body.messages.map((m) => m.role),
|
||||||
|
['system', 'user', 'assistant', 'user', 'assistant', 'user'],
|
||||||
|
'历史必须在 messages 里,且当前轮排最后'
|
||||||
|
);
|
||||||
|
assert.strictEqual(body.messages[1].content, '第一个问题');
|
||||||
|
assert.strictEqual(body.messages[2].content, '第一个回答');
|
||||||
|
assert.strictEqual(body.messages[3].content, '第二个问题');
|
||||||
|
assert.strictEqual(body.messages[4].content, '第二个回答');
|
||||||
|
assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失');
|
||||||
|
assert.match(body.messages[5].content, /正文/, '当前轮正文丢失');
|
||||||
|
assert.ok(
|
||||||
|
!/第一个问题/.test(body.messages[0].content),
|
||||||
|
'历史不该被塞进 system,模型会把它当成指令'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anthropic 历史进 messages,system 仍在顶层', async () => {
|
||||||
|
const body = await captureBody('anthropic', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '新问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '旧问题' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.strictEqual(typeof body.system, 'string');
|
||||||
|
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
|
||||||
|
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||||
|
assert.strictEqual(body.messages[0].content, '旧问题');
|
||||||
|
assert.strictEqual(body.messages[1].content, '旧回答');
|
||||||
|
assert.match(body.messages[2].content, /新问题/);
|
||||||
|
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openai-responses 历史进 input,instructions 与 store:false 保持', async () => {
|
||||||
|
const body = await captureBody('openai-responses', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '新问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '旧问题' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.strictEqual(typeof body.instructions, 'string');
|
||||||
|
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
|
||||||
|
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
|
||||||
|
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
|
||||||
|
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||||
|
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
|
||||||
|
assert.strictEqual(body.input[0].content, '旧问题');
|
||||||
|
assert.strictEqual(body.input[1].content, '旧回答');
|
||||||
|
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
|
||||||
|
assert.strictEqual(body.input[2].content[0].type, 'input_text');
|
||||||
|
assert.match(body.input[2].content[0].text, /新问题/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史里的图像不被重发,只发当前轮的图', async () => {
|
||||||
|
const image = visualContext().image;
|
||||||
|
const history = [
|
||||||
|
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||||
|
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||||
|
];
|
||||||
|
const args = {
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '这张呢',
|
||||||
|
visualContexts: [visualContext()],
|
||||||
|
history
|
||||||
|
};
|
||||||
|
|
||||||
|
const chat = await captureBody('chat-completions', args, { vision: true });
|
||||||
|
const chatImages = chat.messages.flatMap(
|
||||||
|
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
|
||||||
|
);
|
||||||
|
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
|
||||||
|
|
||||||
|
const anthropic = await captureBody('anthropic', args, { vision: true });
|
||||||
|
const anthropicImages = anthropic.messages.flatMap(
|
||||||
|
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
|
||||||
|
);
|
||||||
|
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
|
||||||
|
|
||||||
|
const responses = await captureBody('openai-responses', args, { vision: true });
|
||||||
|
const responseImages = responses.input.flatMap(
|
||||||
|
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
|
||||||
|
);
|
||||||
|
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
|
||||||
|
|
||||||
|
const historyJson = JSON.stringify(history);
|
||||||
|
assert.strictEqual(historyJson, JSON.stringify([
|
||||||
|
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||||
|
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||||
|
]), '不该原地改写调用方传进来的历史数组');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
|
||||||
|
const body = await captureBody('anthropic', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '新问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
|
||||||
|
{ id: 'b', role: 'user', text: '真正的第一问' },
|
||||||
|
{ id: 'c', role: 'assistant', text: '第一答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
|
||||||
|
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
|
||||||
|
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||||
|
assert.strictEqual(body.messages[0].content, '真正的第一问');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '当前问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '上一问' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '上一答' },
|
||||||
|
{ id: 'c', role: 'user', text: '没等到回答的追问' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
|
||||||
|
}
|
||||||
|
const flat = JSON.stringify(items);
|
||||||
|
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
|
||||||
|
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史内部相邻同角色被合并而不是丢弃', async () => {
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '当前问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '连问一' },
|
||||||
|
{ id: 'b', role: 'user', text: '连问二' },
|
||||||
|
{ id: 'c', role: 'assistant', text: '连答一' },
|
||||||
|
{ id: 'd', role: 'assistant', text: '连答二' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
roles,
|
||||||
|
['user', 'assistant', 'user'],
|
||||||
|
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
|
||||||
|
);
|
||||||
|
const flat = JSON.stringify(items);
|
||||||
|
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
|
||||||
|
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史含空文本时不产生空 content', async () => {
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '当前问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '有效提问' },
|
||||||
|
{ id: 'b', role: 'assistant', text: ' ' },
|
||||||
|
{ id: 'c', role: 'assistant', text: '' },
|
||||||
|
{ id: 'd', role: 'user', text: null },
|
||||||
|
{ id: 'e', role: 'assistant', text: '有效回答' },
|
||||||
|
null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
for (const item of items) {
|
||||||
|
const text = typeof item.content === 'string'
|
||||||
|
? item.content
|
||||||
|
: JSON.stringify(item.content);
|
||||||
|
assert.ok(text && text.trim(), `${protocol} 出现空 content,Anthropic 不接受空字符串`);
|
||||||
|
}
|
||||||
|
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||||
|
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史按旧到新排列,当前轮在最后', async () => {
|
||||||
|
const history = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
|
||||||
|
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
|
||||||
|
}
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '问题4',
|
||||||
|
history
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
|
||||||
|
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
|
||||||
|
.map((mark) => flat.findIndex((s) => s.includes(mark)));
|
||||||
|
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
|
||||||
|
for (let i = 1; i < order.length; i++) {
|
||||||
|
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
|
||||||
|
}
|
||||||
|
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMessages 也接受历史参数', () => {
|
||||||
|
const ai = setup();
|
||||||
|
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
|
||||||
|
{ role: 'user', text: '旧问题' },
|
||||||
|
{ role: 'assistant', text: '旧回答' }
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
|
||||||
|
assert.strictEqual(msgs[1].content, '旧问题');
|
||||||
|
assert.match(msgs[3].content, /新问题/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const modulePath = require.resolve('../reader/annotations.js');
|
||||||
|
const dirs = [];
|
||||||
|
|
||||||
|
function fresh() {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-annotations-'));
|
||||||
|
dirs.push(dir);
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
const store = require(modulePath);
|
||||||
|
store.init(dir);
|
||||||
|
return { store, dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function key(name) {
|
||||||
|
return crypto.createHash('sha256').update(name).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每个条目保存到独立批注文件', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
store.setPage('book_a', key('a.pdf'), 1, { objects: [{ type: 'Rect', left: 10 }] });
|
||||||
|
store.setPage('book_b', key('b.pdf'), 2, { objects: [{ type: 'Path' }] });
|
||||||
|
const files = fs.readdirSync(path.join(dir, 'reader-annotations')).sort();
|
||||||
|
assert.deepStrictEqual(files, ['book_a.json', 'book_b.json']);
|
||||||
|
assert.strictEqual(store.get('book_a', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
|
||||||
|
assert.strictEqual(store.get('book_b', key('b.pdf')).pages['2'].objects[0].type, 'Path');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('文档指纹在文件移动后保持稳定,内容变化后更新', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const first = path.join(dir, 'first.pdf');
|
||||||
|
const moved = path.join(dir, 'moved.pdf');
|
||||||
|
const bytes = Buffer.alloc(256 * 1024, 1);
|
||||||
|
fs.writeFileSync(first, bytes);
|
||||||
|
const before = store.documentKey(first);
|
||||||
|
fs.renameSync(first, moved);
|
||||||
|
assert.strictEqual(store.documentKey(moved), before);
|
||||||
|
bytes[128 * 1024] = 2;
|
||||||
|
fs.writeFileSync(moved, bytes);
|
||||||
|
assert.notStrictEqual(store.documentKey(moved), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大文档指纹只采样首中尾且小文档保持完整 SHA-256', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const small = path.join(dir, 'small.pdf');
|
||||||
|
const smallBytes = Buffer.alloc(4096, 7);
|
||||||
|
fs.writeFileSync(small, smallBytes);
|
||||||
|
assert.strictEqual(
|
||||||
|
store.hashDocumentFile(small, smallBytes.length, 8192),
|
||||||
|
crypto.createHash('sha256').update(smallBytes).digest('hex')
|
||||||
|
);
|
||||||
|
|
||||||
|
const large = path.join(dir, 'large.pdf');
|
||||||
|
const largeBytes = Buffer.alloc(12 * 1024 * 1024, 3);
|
||||||
|
fs.writeFileSync(large, largeBytes);
|
||||||
|
const before = store.hashDocumentFile(large, largeBytes.length, 1024 * 1024);
|
||||||
|
const fd = fs.openSync(large, 'r+');
|
||||||
|
try {
|
||||||
|
fs.writeSync(fd, Buffer.from([9]), 0, 1, 6 * 1024 * 1024);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
const after = store.hashDocumentFile(large, largeBytes.length, 1024 * 1024);
|
||||||
|
assert.notStrictEqual(after, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('生成指纹期间文件持续变化时拒绝返回混合版本标识', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const file = path.join(dir, 'changing.pdf');
|
||||||
|
fs.writeFileSync(file, Buffer.alloc(4096, 1));
|
||||||
|
const originalStat = fs.statSync;
|
||||||
|
let calls = 0;
|
||||||
|
fs.statSync = function (target, ...args) {
|
||||||
|
const stat = originalStat.call(fs, target, ...args);
|
||||||
|
if (path.resolve(String(target)) === path.resolve(file)) {
|
||||||
|
Object.defineProperty(stat, 'mtimeMs', { value: stat.mtimeMs + calls++ });
|
||||||
|
}
|
||||||
|
return stat;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
assert.throws(() => store.documentKey(file), /生成指纹期间发生变化/);
|
||||||
|
} finally {
|
||||||
|
fs.statSync = originalStat;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同一条目的不同 PDF 文件与页码互相隔离', () => {
|
||||||
|
const { store } = fresh();
|
||||||
|
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'IText', text: 'A' }] });
|
||||||
|
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'IText', text: 'B' }] });
|
||||||
|
store.setPage('book', key('a.pdf'), 2, { objects: [{ type: 'Rect' }] });
|
||||||
|
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].text, 'A');
|
||||||
|
assert.strictEqual(store.get('book', key('b.pdf')).pages['1'].objects[0].text, 'B');
|
||||||
|
assert.strictEqual(store.get('book', key('a.pdf')).pages['2'].objects[0].type, 'Rect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空对象列表删除当前页但保留其它页', () => {
|
||||||
|
const { store } = fresh();
|
||||||
|
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
store.setPage('book', key('a.pdf'), 2, { objects: [{ type: 'Path' }] });
|
||||||
|
store.setPage('book', key('a.pdf'), 1, { objects: [] });
|
||||||
|
const pages = store.get('book', key('a.pdf')).pages;
|
||||||
|
assert.strictEqual(pages['1'], undefined);
|
||||||
|
assert.strictEqual(pages['2'].objects.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get 返回深拷贝,外部修改不污染缓存文件', () => {
|
||||||
|
const { store } = fresh();
|
||||||
|
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect', left: 5 }] });
|
||||||
|
const first = store.get('book', key('a.pdf'));
|
||||||
|
first.pages['1'].objects[0].left = 999;
|
||||||
|
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].left, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('拒绝路径穿越、非法页码和异常大的单页数据', () => {
|
||||||
|
const { store } = fresh();
|
||||||
|
assert.throws(() => store.get('../outside', key('a.pdf')), /ID/);
|
||||||
|
assert.throws(() => store.setPage('book', 'bad', 1, { objects: [] }), /标识/);
|
||||||
|
assert.throws(() => store.setPage('book', key('a.pdf'), 0, { objects: [] }), /页码/);
|
||||||
|
assert.throws(() => store.setPage('book', key('a.pdf'), 1, { objects: [{ text: 'x'.repeat(2 * 1024 * 1024) }] }), /过大/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('损坏文件回退为空,后续写入可恢复', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const folder = path.join(dir, 'reader-annotations');
|
||||||
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(folder, 'book.json'), '{ bad');
|
||||||
|
assert.deepStrictEqual(store.get('book', key('a.pdf')).pages, {});
|
||||||
|
assert.ok(fs.readdirSync(folder).some((name) => name.startsWith('book.json.corrupt-')));
|
||||||
|
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('主文件损坏时优先从原子写入备份恢复', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
const file = path.join(dir, 'reader-annotations', 'book.json');
|
||||||
|
fs.copyFileSync(file, `${file}.bak`);
|
||||||
|
fs.writeFileSync(file, '{ bad');
|
||||||
|
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('批注计数统计对象总数,随内容变化失效', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const k = key('a.pdf');
|
||||||
|
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }, { type: 'Path' }] });
|
||||||
|
store.setPage('book', k, 4, { objects: [{ type: 'IText' }] });
|
||||||
|
// 是对象总数而非批注页数
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 3 });
|
||||||
|
|
||||||
|
// mtime + size 缓存必须在内容变化后失效
|
||||||
|
store.setPage('book', k, 4, { objects: [] });
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
store.setPage('book', k, 1, { objects: [] });
|
||||||
|
assert.deepStrictEqual(store.getCounts(), {});
|
||||||
|
|
||||||
|
// 同一条目的不同文档版本累加
|
||||||
|
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
|
||||||
|
// 损坏文件计 0 而不是抛错,否则整个书库都拿不到计数
|
||||||
|
fs.writeFileSync(path.join(dir, 'reader-annotations', 'broken.json'), '{ bad');
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
|
||||||
|
store.forget('book');
|
||||||
|
assert.deepStrictEqual(store.getCounts(), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('删除后重建同名批注文件不会命中旧缓存', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const folder = path.join(dir, 'reader-annotations');
|
||||||
|
const file = path.join(folder, 'book.json');
|
||||||
|
const k = key('a.pdf');
|
||||||
|
store.setPage('book', k, 1, { objects: [{ t: 'aa' }, { t: 'bb' }] });
|
||||||
|
// 固定到整毫秒,否则 utimesSync 无法精确复现原 mtime
|
||||||
|
const pinned = new Date(1700000000000);
|
||||||
|
fs.utimesSync(file, pinned, pinned);
|
||||||
|
const stale = fs.statSync(file);
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
|
||||||
|
// 构造与旧文件同尺寸、同 mtime 但内容不同的文件:
|
||||||
|
// 缓存键只有 mtime+size,forget 不清缓存就会返回过期的 2
|
||||||
|
store.forget('book');
|
||||||
|
const rebuilt = JSON.parse(JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
entryId: 'book',
|
||||||
|
documents: { [k]: { pages: { 1: { objects: [{ t: 'aa' }], updatedAt: 0 } }, updatedAt: 0 } }
|
||||||
|
}));
|
||||||
|
let text = JSON.stringify(rebuilt, null, 2);
|
||||||
|
assert.ok(text.length < stale.size, '重建内容应短于原文件才能补齐到同尺寸');
|
||||||
|
rebuilt.documents[k].pages['1'].objects[0].t = 'aa'.padEnd(2 + (stale.size - text.length), 'z');
|
||||||
|
text = JSON.stringify(rebuilt, null, 2);
|
||||||
|
assert.strictEqual(Buffer.byteLength(text, 'utf8'), stale.size, '需精确构造同尺寸文件');
|
||||||
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
fs.writeFileSync(file, text, 'utf8');
|
||||||
|
fs.utimesSync(file, pinned, pinned);
|
||||||
|
assert.strictEqual(fs.statSync(file).mtimeMs, stale.mtimeMs, '需精确复现同 mtime');
|
||||||
|
assert.strictEqual(fs.statSync(file).size, stale.size, '需精确复现同尺寸');
|
||||||
|
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('孤立批注按体积报告并可批量回收', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const k = key('a.pdf');
|
||||||
|
store.setPage('kept', k, 1, { objects: [{ t: 'Rect' }] });
|
||||||
|
store.setPage('gone_a', k, 1, { objects: [{ t: 'Rect' }, { t: 'Path' }] });
|
||||||
|
store.setPage('gone_b', k, 1, { objects: [{ t: 'IText' }] });
|
||||||
|
|
||||||
|
const orphans = store.orphanReport(['kept']);
|
||||||
|
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
|
||||||
|
assert.ok(orphans.every((o) => o.bytes > 0), '需报告体积供用户判断');
|
||||||
|
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').count, 2);
|
||||||
|
|
||||||
|
// 仍在书库的条目绝不能出现在回收清单里
|
||||||
|
assert.ok(!orphans.some((o) => o.entryId === 'kept'));
|
||||||
|
|
||||||
|
assert.strictEqual(store.forgetMany(orphans.map((o) => o.entryId)), 2);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'gone_a.json')), false);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'kept.json')), true);
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||||
|
assert.deepStrictEqual(store.orphanReport(['kept']), []);
|
||||||
|
|
||||||
|
// 传入非法 ID 不应中断其余回收
|
||||||
|
store.setPage('gone_c', k, 1, { objects: [{ t: 'Rect' }] });
|
||||||
|
assert.strictEqual(store.forgetMany(['../escape', 'gone_c']), 1);
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forget 删除条目批注及备份残留', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
const file = path.join(dir, 'reader-annotations', 'book.json');
|
||||||
|
fs.writeFileSync(`${file}.bak`, '{}');
|
||||||
|
fs.writeFileSync(`${file}.corrupt-1`, '{ bad');
|
||||||
|
assert.strictEqual(store.forget('book'), true);
|
||||||
|
assert.strictEqual(fs.existsSync(file), false);
|
||||||
|
assert.strictEqual(fs.existsSync(`${file}.bak`), false);
|
||||||
|
assert.strictEqual(fs.existsSync(`${file}.corrupt-1`), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const atomic = require('../atomic-file');
|
||||||
|
const dirs = [];
|
||||||
|
|
||||||
|
function fresh(tag) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-atomic-${tag}-`));
|
||||||
|
dirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('元数据写入在改名前 fsync 数据', () => {
|
||||||
|
const dir = fresh('fsync');
|
||||||
|
const dest = path.join(dir, 'meta.json');
|
||||||
|
const order = [];
|
||||||
|
const realFsync = fs.fsyncSync;
|
||||||
|
const realRename = fs.renameSync;
|
||||||
|
fs.fsyncSync = function tracked(fd) {
|
||||||
|
order.push('fsync');
|
||||||
|
return realFsync.call(this, fd);
|
||||||
|
};
|
||||||
|
fs.renameSync = function tracked(from, to) {
|
||||||
|
order.push(`rename:${path.basename(String(from))}->${path.basename(String(to))}`);
|
||||||
|
return realRename.call(this, from, to);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
atomic.writeJson(dest, { hello: '世界' });
|
||||||
|
} finally {
|
||||||
|
fs.fsyncSync = realFsync;
|
||||||
|
fs.renameSync = realRename;
|
||||||
|
}
|
||||||
|
// rename 只保证目录项替换原子,不保证数据已落盘;
|
||||||
|
// 因此临时文件的 fsync 必须发生在改名之前
|
||||||
|
const firstRename = order.findIndex((step) => step.startsWith('rename:'));
|
||||||
|
assert.ok(firstRename > 0, `改名前应先 fsync,实际顺序 ${order.join(',')}`);
|
||||||
|
assert.strictEqual(order[0], 'fsync');
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { hello: '世界' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('写入失败时清理临时文件并把备份换回原位', () => {
|
||||||
|
const dir = fresh('rollback');
|
||||||
|
const dest = path.join(dir, 'meta.json');
|
||||||
|
atomic.writeJson(dest, { round: 1 });
|
||||||
|
const before = fs.readFileSync(dest, 'utf8');
|
||||||
|
|
||||||
|
const realRename = fs.renameSync;
|
||||||
|
let failed = false;
|
||||||
|
fs.renameSync = function failing(from, to) {
|
||||||
|
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
|
||||||
|
failed = true;
|
||||||
|
throw new Error('模拟替换失败');
|
||||||
|
}
|
||||||
|
return realRename.apply(this, arguments);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
assert.throws(() => atomic.writeJson(dest, { round: 2 }), /模拟替换失败/);
|
||||||
|
} finally {
|
||||||
|
fs.renameSync = realRename;
|
||||||
|
}
|
||||||
|
assert.ok(failed);
|
||||||
|
assert.strictEqual(fs.readFileSync(dest, 'utf8'), before);
|
||||||
|
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
|
||||||
|
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目录 fsync 失败不影响写入结果', () => {
|
||||||
|
const dir = fresh('dirfail');
|
||||||
|
const dest = path.join(dir, 'meta.json');
|
||||||
|
const realOpen = fs.openSync;
|
||||||
|
// Windows 无法对目录取句柄,这条路径必须容错
|
||||||
|
fs.openSync = function guarded(target, flags, ...rest) {
|
||||||
|
if (String(target) === dir) throw new Error('EISDIR');
|
||||||
|
return realOpen.call(this, target, flags, ...rest);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
atomic.writeJson(dest, { ok: true });
|
||||||
|
} finally {
|
||||||
|
fs.openSync = realOpen;
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const authPath = require.resolve('../sources/zlib-auth.js');
|
||||||
|
const keyPath = require.resolve('../sources/semantic-key.js');
|
||||||
|
|
||||||
|
const dirs = [];
|
||||||
|
function tmp() {
|
||||||
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-auth-'));
|
||||||
|
dirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
test.after(() => {
|
||||||
|
for (const d of dirs) {
|
||||||
|
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模拟 Electron safeStorage:加密就是加个前缀 + base64,能验证"没有明文落盘"
|
||||||
|
function fakeStorage(available = true) {
|
||||||
|
return {
|
||||||
|
isEncryptionAvailable: () => available,
|
||||||
|
encryptString: (s) => Buffer.from('ENC:' + Buffer.from(s, 'utf8').toString('base64')),
|
||||||
|
decryptString: (buf) => {
|
||||||
|
const s = buf.toString();
|
||||||
|
if (!s.startsWith('ENC:')) throw new Error('bad ciphertext');
|
||||||
|
return Buffer.from(s.slice(4), 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshAuth() {
|
||||||
|
delete require.cache[authPath];
|
||||||
|
return require(authPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('凭据加密落盘,磁盘上没有明文密码', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage());
|
||||||
|
auth.write({ email: 'me@example.com', password: 'SuperSecret123', userId: '7', userKey: 'k' });
|
||||||
|
|
||||||
|
const all = fs.readdirSync(d).map((f) => fs.readFileSync(path.join(d, f)).toString());
|
||||||
|
for (const content of all) {
|
||||||
|
assert.ok(!content.includes('SuperSecret123'), '磁盘上出现了明文密码: ' + content.slice(0, 120));
|
||||||
|
assert.ok(!content.includes(Buffer.from('SuperSecret123').toString('base64')),
|
||||||
|
'密码只做了 base64 混淆');
|
||||||
|
}
|
||||||
|
const back = auth.read();
|
||||||
|
assert.strictEqual(back.password, 'SuperSecret123');
|
||||||
|
assert.strictEqual(back.email, 'me@example.com');
|
||||||
|
assert.strictEqual(back.userId, '7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('旧版 base64 数据自动迁移并抹掉明文', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const legacy = {
|
||||||
|
email: Buffer.from('old@example.com').toString('base64'),
|
||||||
|
password: Buffer.from('OldPass').toString('base64'),
|
||||||
|
userId: '1', userKey: 'ukey', mirror: 'https://z-lib.fm'
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(d, 'zlib-auth.json'), JSON.stringify(legacy));
|
||||||
|
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage());
|
||||||
|
const c = auth.read();
|
||||||
|
assert.strictEqual(c.email, 'old@example.com', '迁移后邮箱丢失');
|
||||||
|
assert.strictEqual(c.password, 'OldPass', '迁移后密码丢失');
|
||||||
|
assert.strictEqual(c.userKey, 'ukey', '会话字段应保留');
|
||||||
|
|
||||||
|
const json = fs.readFileSync(path.join(d, 'zlib-auth.json'), 'utf8');
|
||||||
|
assert.ok(!json.includes(legacy.password), '旧的明文/混淆密码没有被抹掉');
|
||||||
|
assert.ok(fs.existsSync(path.join(d, 'zlib-auth.cred')), '未生成加密文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('系统不支持加密时绝不把密码写到磁盘', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage(false));
|
||||||
|
auth.write({ email: 'a@b.c', password: 'PlainSecret' });
|
||||||
|
|
||||||
|
for (const f of fs.readdirSync(d)) {
|
||||||
|
const content = fs.readFileSync(path.join(d, f)).toString();
|
||||||
|
assert.ok(!content.includes('PlainSecret'), `${f} 里落了明文密码`);
|
||||||
|
}
|
||||||
|
// 本进程内仍可用
|
||||||
|
assert.strictEqual(auth.read().password, 'PlainSecret');
|
||||||
|
assert.strictEqual(auth.hasCreds(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSession 不会因为读取失败清空凭据', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage());
|
||||||
|
auth.write({ email: 'x@y.z', password: 'Keep', userId: '', userKey: '' });
|
||||||
|
auth.setSession('99', 'newkey', 'https://z-lib.fm');
|
||||||
|
|
||||||
|
const c = auth.read();
|
||||||
|
assert.strictEqual(c.password, 'Keep', 'setSession 吞掉了密码');
|
||||||
|
assert.strictEqual(c.email, 'x@y.z');
|
||||||
|
assert.strictEqual(c.userId, '99');
|
||||||
|
assert.strictEqual(c.mirror, 'https://z-lib.fm');
|
||||||
|
|
||||||
|
// setSession 只应改会话字段,绝不能把凭据顺手写进明文 meta 文件
|
||||||
|
const meta = fs.readFileSync(path.join(d, 'zlib-auth.json'), 'utf8');
|
||||||
|
assert.ok(!meta.includes('Keep'), 'setSession 把明文密码写进了 json');
|
||||||
|
assert.ok(!meta.includes('x@y.z'), 'setSession 把明文邮箱写进了 json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearSession 保留凭据,clear 全部清掉', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage());
|
||||||
|
auth.write({ email: 'x@y.z', password: 'Keep', userId: '1', userKey: 'k', mirror: 'm' });
|
||||||
|
|
||||||
|
auth.clearSession();
|
||||||
|
assert.strictEqual(auth.getSession(), null, '会话未清除');
|
||||||
|
assert.strictEqual(auth.hasCreds(), true, 'clearSession 不该动凭据');
|
||||||
|
assert.strictEqual(auth.read().password, 'Keep');
|
||||||
|
|
||||||
|
auth.clear();
|
||||||
|
assert.strictEqual(auth.hasCreds(), false);
|
||||||
|
assert.strictEqual(auth.read(), null);
|
||||||
|
assert.ok(!fs.existsSync(path.join(d, 'zlib-auth.cred')), '密文文件未删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('customMirrors 往返不丢失', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage());
|
||||||
|
auth.write({ email: 'a@b.c', password: 'p', customMirrors: ['https://m1', 'https://m2'] });
|
||||||
|
assert.deepStrictEqual(auth.read().customMirrors, ['https://m1', 'https://m2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('损坏的密文不影响会话字段读取', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage());
|
||||||
|
auth.write({ email: 'a@b.c', password: 'p', userId: '5', userKey: 'kk' });
|
||||||
|
fs.writeFileSync(path.join(d, 'zlib-auth.cred'), 'garbage');
|
||||||
|
|
||||||
|
const c = auth.read();
|
||||||
|
assert.strictEqual(c.password, '', '损坏密文应视为无凭据');
|
||||||
|
assert.strictEqual(c.userId, '5', '会话字段不该受影响');
|
||||||
|
assert.strictEqual(auth.hasCreds(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('写入是原子的,不留 .tmp 残留', () => {
|
||||||
|
const d = tmp();
|
||||||
|
const auth = freshAuth();
|
||||||
|
auth.init(d, fakeStorage());
|
||||||
|
auth.write({ email: 'a@b.c', password: 'p', userId: '1', userKey: 'k' });
|
||||||
|
const leftovers = fs.readdirSync(d).filter((f) => f.endsWith('.tmp'));
|
||||||
|
assert.deepStrictEqual(leftovers, [], '存在临时文件残留');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- semantic-key ---
|
||||||
|
|
||||||
|
test('semantic-key: 解密失败不被永久缓存,可自愈', () => {
|
||||||
|
const d = tmp();
|
||||||
|
delete require.cache[keyPath];
|
||||||
|
const sk = require(keyPath);
|
||||||
|
const storage = fakeStorage();
|
||||||
|
sk.init(d, storage);
|
||||||
|
sk.write('real-api-key');
|
||||||
|
assert.strictEqual(sk.read(), 'real-api-key');
|
||||||
|
|
||||||
|
// 模拟一次临时读取失败(文件被占用等)
|
||||||
|
const file = path.join(d, 'semantic-scholar-key.bin');
|
||||||
|
const good = fs.readFileSync(file);
|
||||||
|
fs.writeFileSync(file, 'corrupted');
|
||||||
|
delete require.cache[keyPath];
|
||||||
|
const sk2 = require(keyPath);
|
||||||
|
sk2.init(d, storage);
|
||||||
|
assert.strictEqual(sk2.read(), '', '损坏时应返回空');
|
||||||
|
// 恢复后同一进程内必须能重新读到,不能被空值缓存钉死
|
||||||
|
fs.writeFileSync(file, good);
|
||||||
|
assert.strictEqual(sk2.read(), 'real-api-key', '临时失败被永久缓存了');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('semantic-key: 未配置时稳定返回空', () => {
|
||||||
|
const d = tmp();
|
||||||
|
delete require.cache[keyPath];
|
||||||
|
const sk = require(keyPath);
|
||||||
|
sk.init(d, fakeStorage());
|
||||||
|
assert.strictEqual(sk.read(), '');
|
||||||
|
assert.strictEqual(sk.status().configured, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
// 验证 AI 上下文范围由用户选择,且大上下文必须确认后才外发。
|
||||||
|
// 用真实的本地 OpenAI 兼容服务接收请求,断言"实际离开进程的内容",而不是 stub 渲染层。
|
||||||
|
const { app, BrowserWindow, clipboard, shell, nativeImage } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'plscope-'));
|
||||||
|
app.setPath('userData', TMP);
|
||||||
|
app.setPath('appData', TMP);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function chk(name, cond, extra = '') { results.push([cond ? 'OK' : 'FAIL', name, extra]); }
|
||||||
|
const openedExternal = [];
|
||||||
|
const openExternalStub = async (url) => { openedExternal.push(url); };
|
||||||
|
shell.openExternal = openExternalStub;
|
||||||
|
if (shell.openExternal !== openExternalStub) throw new Error('无法隔离外部链接测试');
|
||||||
|
|
||||||
|
const AI_MARKDOWN = [
|
||||||
|
'# 回答\n\n',
|
||||||
|
'1. **第一项**\n2. 第二项\n\n',
|
||||||
|
'```js\nconsole.log("safe")\n```\n\n',
|
||||||
|
'| 项目 | 结论 |\n| --- | --- |\n| A | 可用 |\n\n',
|
||||||
|
'[安全链接](https://example.com/path)\n\n',
|
||||||
|
'[危险链接](javascript:alert(1))\n\n',
|
||||||
|
'<img src=x onerror="window.__aiXss=true">\n\n',
|
||||||
|
''
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
// 真实的本地模型服务:记录每次收到的 body
|
||||||
|
const received = [];
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(body); } catch (e) { parsed = { parseError: body.slice(0, 80) }; }
|
||||||
|
received.push(parsed);
|
||||||
|
requests.push({ url: req.url, headers: req.headers, body: parsed });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||||
|
if (req.url.endsWith('/messages')) {
|
||||||
|
res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Anthropic 正常' } })}\n\n`);
|
||||||
|
res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url.endsWith('/responses')) {
|
||||||
|
res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'Responses 正常' })}\n\n`);
|
||||||
|
res.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(0, 80) } }] })}\n\n`);
|
||||||
|
setTimeout(() => {
|
||||||
|
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(80) } }] })}\n\n`);
|
||||||
|
res.write('data: [DONE]\n\n');
|
||||||
|
res.end();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function charsOf(request) {
|
||||||
|
const msgs = (request && request.messages) || [];
|
||||||
|
return msgs.reduce((k, m) => k + (typeof (m && m.content) === 'string' ? m.content.length : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
const epubPath = path.join(os.tmpdir(), 'plscope-cache', 's.epub');
|
||||||
|
fs.mkdirSync(path.dirname(epubPath), { recursive: true });
|
||||||
|
if (!fs.existsSync(epubPath)) {
|
||||||
|
const { fetch: uf, ProxyAgent } = require('undici');
|
||||||
|
// 代理只在设了 HTTPS_PROXY 时用,写死本机代理会让 CI 直接 ECONNREFUSED
|
||||||
|
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || '';
|
||||||
|
const r = await uf('https://www.gutenberg.org/ebooks/11.epub.noimages', {
|
||||||
|
dispatcher: proxy ? new ProxyAgent({ uri: proxy, connectTimeout: 30000 }) : undefined
|
||||||
|
});
|
||||||
|
fs.writeFileSync(epubPath, Buffer.from(await r.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
require(path.join(ROOT, 'main.js'));
|
||||||
|
const settings = require(path.join(ROOT, 'src', 'settings'));
|
||||||
|
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
||||||
|
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
||||||
|
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||||||
|
settings.init(TMP);
|
||||||
|
readerStore.init(TMP);
|
||||||
|
aiConfig.init(TMP, require('electron').safeStorage);
|
||||||
|
library.init(path.join(TMP, 'library'));
|
||||||
|
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
|
||||||
|
|
||||||
|
aiConfig.save({
|
||||||
|
protocol: 'chat-completions',
|
||||||
|
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||||
|
model: 'test-model',
|
||||||
|
apiKey: '',
|
||||||
|
vision: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const e = library.add({ title: 'Alice', authors: [], files: [{ path: epubPath, name: 's.epub', format: 'EPUB' }] });
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
|
||||||
|
for (const w of BrowserWindow.getAllWindows()) w.hide();
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false, width: 1200, height: 860,
|
||||||
|
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
|
||||||
|
});
|
||||||
|
const errs = [];
|
||||||
|
win.webContents.on('console-message', (event) => {
|
||||||
|
const { level, message } = event;
|
||||||
|
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
|
||||||
|
errs.push(message.slice(0, 120));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
|
||||||
|
await new Promise((r) => setTimeout(r, 9000));
|
||||||
|
|
||||||
|
const js = async (code) => {
|
||||||
|
try { return await win.webContents.executeJavaScript(code); }
|
||||||
|
catch (err) { return 'ERR ' + err.message.slice(0, 90); }
|
||||||
|
};
|
||||||
|
|
||||||
|
await js("(function(){var n=document.querySelectorAll('#tocList [data-idx], #tocList .toc-item, #tocList button');if(n[3])n[3].click();return n.length})()");
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
chk('上下文选择器存在', (await js("!!document.getElementById('aiScope')")) === true);
|
||||||
|
chk('默认范围是"仅选中文本"', (await js("document.getElementById('aiScope').value")) === 'selection');
|
||||||
|
chk('文本和图像五个范围选项齐全',
|
||||||
|
(await js("Array.from(document.getElementById('aiScope').options).map(o=>o.value).join(',')")) === 'selection,page,document,page-image,region-image');
|
||||||
|
|
||||||
|
await js("document.querySelector('[data-pane=\"ai\"]').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
chk('未选中文本时给出提示', String(await js("document.getElementById('aiCost').textContent")).includes('未选中'));
|
||||||
|
|
||||||
|
// 打开阅读器并静置:不应有任何请求发往模型
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
chk('空闲时不会自动调用模型', received.length === 0, '请求数=' + received.length);
|
||||||
|
|
||||||
|
// 页面/全文范围不依赖选中文本:此时正文里没有任何选区
|
||||||
|
chk('切换范围前确实没有选中文本',
|
||||||
|
(await js("String(window.getSelection() ? window.getSelection().toString() : '').trim().length")) === 0);
|
||||||
|
|
||||||
|
await js("var s=document.getElementById('aiScope'); s.value='page'; s.dispatchEvent(new Event('change'));");
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
const pageCostText = String(await js("document.getElementById('aiCost').textContent"));
|
||||||
|
chk('未选中文本时当前页范围仍可估算', /字.*tokens/.test(pageCostText), pageCostText);
|
||||||
|
const pageChars = Number((/([\d,]+)\s*字/.exec(pageCostText) || [0, '0'])[1].replace(/,/g, ''));
|
||||||
|
chk('当前页范围估算出非空正文', pageChars > 0, '字数=' + pageChars);
|
||||||
|
|
||||||
|
await js("var s=document.getElementById('aiScope'); s.value='document'; s.dispatchEvent(new Event('change'));");
|
||||||
|
await new Promise((r) => setTimeout(r, 8000));
|
||||||
|
const costText = String(await js("document.getElementById('aiCost').textContent"));
|
||||||
|
chk('全文范围显示字数与 token 估算', /字.*tokens/.test(costText), costText);
|
||||||
|
chk('全文范围提示可能超过模型限制', /可能超过模型限制/.test(costText), costText);
|
||||||
|
const docChars = Number((/([\d,]+)\s*字/.exec(costText) || [0, '0'])[1].replace(/,/g, ''));
|
||||||
|
chk('全文范围覆盖整本而不仅当前页', docChars > pageChars * 5, `全文=${docChars} 当前页=${pageChars}`);
|
||||||
|
|
||||||
|
// 全文提问 + 用户拒绝 => 一个字都不该发出去
|
||||||
|
await js("document.getElementById('aiQuestion').value='这章讲了什么';document.getElementById('aiSendBtn').click();");
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
chk('大上下文会显示应用内确认框',
|
||||||
|
(await js("!document.getElementById('aiConfirmModal').classList.contains('hidden')")) === true);
|
||||||
|
const summary = String(await js(
|
||||||
|
"document.getElementById('aiConfirmScope').textContent+' '+document.getElementById('aiConfirmCost').textContent"
|
||||||
|
));
|
||||||
|
chk('确认框包含范围、字数与 token 估算', /全文/.test(summary) && /字/.test(summary) && /tokens/.test(summary), summary);
|
||||||
|
chk('确认框说明全文完整发送且超限由接口报错',
|
||||||
|
/完整发送/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
|
||||||
|
chk('确认框使用应用按钮而非原生弹窗',
|
||||||
|
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
|
||||||
|
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
|
||||||
|
fs.writeFileSync(path.join(captureDir, 'ai-send-confirmation.png'), (await win.webContents.capturePage()).toPNG());
|
||||||
|
await js("document.getElementById('aiConfirmCancelBtn').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
chk('用户拒绝后没有任何外发请求', received.length === 0, '请求数=' + received.length);
|
||||||
|
chk('提问框内容在取消后保留', (await js("document.getElementById('aiQuestion').value")) === '这章讲了什么');
|
||||||
|
|
||||||
|
// 用户同意 => 才真正发送全文
|
||||||
|
await js("document.getElementById('aiSendBtn').click();");
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
await js("document.getElementById('aiConfirmSendBtn').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 180));
|
||||||
|
chk('流式传输过程中稳定渲染不完整 Markdown', await js(`(() => {
|
||||||
|
const output = document.getElementById('aiOutput');
|
||||||
|
return output.classList.contains('streaming')
|
||||||
|
&& output.querySelector('h1')?.textContent === '回答'
|
||||||
|
&& output.textContent.length > 0;
|
||||||
|
})()`));
|
||||||
|
await new Promise((r) => setTimeout(r, 3820));
|
||||||
|
chk('用户同意后发送且仅一次', received.length === 1, '请求数=' + received.length);
|
||||||
|
chk('外发内容为全文正文', charsOf(received[0]) > 1000, '字符=' + charsOf(received[0]));
|
||||||
|
// 界面告知的字数必须等于真正离开进程的字数。旧行为在这里砍掉 80% 正文却仍显示全文字数
|
||||||
|
chk('外发字数与界面告知一致,正文未被静默截断',
|
||||||
|
charsOf(received[0]) >= docChars,
|
||||||
|
`外发=${charsOf(received[0])} 界面告知=${docChars}`);
|
||||||
|
chk('外发正文不含本地截断标记',
|
||||||
|
!JSON.stringify(received[0]).includes('中间省略'));
|
||||||
|
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
|
||||||
|
const output = document.getElementById('aiOutput');
|
||||||
|
return output.querySelector('h1')?.textContent === '回答'
|
||||||
|
&& output.querySelector('strong')?.textContent === '第一项'
|
||||||
|
&& output.querySelectorAll('ol > li').length === 2
|
||||||
|
&& output.querySelector('pre code')?.textContent.includes('console.log')
|
||||||
|
&& output.querySelectorAll('table th').length === 2;
|
||||||
|
})()`));
|
||||||
|
chk('Markdown 链接和图片执行安全策略', await js(`(() => {
|
||||||
|
const output = document.getElementById('aiOutput');
|
||||||
|
const safe = output.querySelector('a[data-external-url]');
|
||||||
|
return safe?.dataset.externalUrl === 'https://example.com/path'
|
||||||
|
&& safe.getAttribute('href') === '#'
|
||||||
|
&& !output.querySelector('a[href^="javascript:"], img, script, iframe, object')
|
||||||
|
&& !!output.querySelector('.ai-md-image-placeholder')
|
||||||
|
&& window.__aiXss !== true;
|
||||||
|
})()`));
|
||||||
|
await js("document.querySelector('#aiOutput a[data-external-url]').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
chk('安全链接通过主进程校验后打开', openedExternal.join(',') === 'https://example.com/path');
|
||||||
|
await js("document.getElementById('aiCopyBtn').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
chk('复制 AI 回答保留原始 Markdown', clipboard.readText() === AI_MARKDOWN);
|
||||||
|
|
||||||
|
await js("var s=document.getElementById('aiScope'); s.value='page-image'; s.dispatchEvent(new Event('change'));");
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const pageVisual = await js(`(() => {
|
||||||
|
const card = document.getElementById('aiVisualCard');
|
||||||
|
const image = document.getElementById('aiVisualPreview');
|
||||||
|
return {
|
||||||
|
visible: !card.classList.contains('hidden'),
|
||||||
|
source: image.getAttribute('src') || '',
|
||||||
|
meta: document.getElementById('aiVisualMeta').textContent,
|
||||||
|
ocrDisabled: document.getElementById('aiOcrBtn').disabled
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
chk('当前页面图像生成内存预览并保留 OCR 入口',
|
||||||
|
pageVisual.visible
|
||||||
|
&& pageVisual.source.startsWith('data:image/jpeg;base64,')
|
||||||
|
&& /\d+ × \d+/.test(pageVisual.meta)
|
||||||
|
&& pageVisual.ocrDisabled);
|
||||||
|
await js("document.getElementById('aiQuestion').value='这张页面图像讲了什么';document.getElementById('aiSendBtn').click();");
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
chk('发送图像前明确显示上传尺寸和数量', await js(`(() => {
|
||||||
|
const modal = document.getElementById('aiConfirmModal');
|
||||||
|
return !modal.classList.contains('hidden')
|
||||||
|
&& document.getElementById('aiConfirmScope').textContent.includes('图像')
|
||||||
|
&& document.getElementById('aiConfirmCost').textContent.includes('1 张图像');
|
||||||
|
})()`));
|
||||||
|
await js("document.getElementById('aiConfirmSendBtn').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
|
// 多轮之后本轮提问固定在末尾,历史占据中间位置,因此不能按固定下标取
|
||||||
|
const pageContent = received[1] && received[1].messages && received[1].messages.at(-1).content;
|
||||||
|
const pageImage = Array.isArray(pageContent)
|
||||||
|
? pageContent.find((part) => part && part.type === 'image_url')
|
||||||
|
: null;
|
||||||
|
const pageImageUrl = pageImage && pageImage.image_url && pageImage.image_url.url;
|
||||||
|
const pageImageBytes = typeof pageImageUrl === 'string'
|
||||||
|
? Buffer.from(pageImageUrl.slice(pageImageUrl.indexOf(',') + 1), 'base64')
|
||||||
|
: Buffer.alloc(0);
|
||||||
|
chk('当前页面仅以内嵌受限图像发送给视觉模型',
|
||||||
|
received.length === 2
|
||||||
|
&& /^data:image\/jpeg;base64,/.test(pageImageUrl || '')
|
||||||
|
&& pageImageBytes.length > 100
|
||||||
|
&& pageImageBytes.length <= 3 * 1024 * 1024);
|
||||||
|
const pageImageSize = nativeImage.createFromBuffer(pageImageBytes).getSize();
|
||||||
|
chk('页面图像压到目标体积以内并限制在 1600px',
|
||||||
|
pageImageBytes.length <= 400 * 1024
|
||||||
|
&& Math.max(pageImageSize.width, pageImageSize.height) <= 1600,
|
||||||
|
`${pageImageSize.width}x${pageImageSize.height} ${Math.round(pageImageBytes.length / 1024)}KB`);
|
||||||
|
|
||||||
|
await js("var s=document.getElementById('aiScope'); s.value='region-image'; s.dispatchEvent(new Event('change'));");
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
const selectionReady = await js(`(() => {
|
||||||
|
const overlay = document.querySelector('.visual-select-overlay');
|
||||||
|
const viewport = document.querySelector('.epub-scroll').getBoundingClientRect();
|
||||||
|
if (!overlay) return false;
|
||||||
|
const x1 = viewport.left + 80;
|
||||||
|
const y1 = viewport.top + 100;
|
||||||
|
const x2 = Math.min(viewport.right - 40, x1 + 300);
|
||||||
|
const y2 = Math.min(viewport.bottom - 40, y1 + 220);
|
||||||
|
overlay.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 41, button: 0, buttons: 1, clientX: x1, clientY: y1 }));
|
||||||
|
overlay.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerId: 41, buttons: 1, clientX: x2, clientY: y2 }));
|
||||||
|
overlay.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 41, button: 0, clientX: x2, clientY: y2 }));
|
||||||
|
const box = overlay.querySelector('.visual-select-box');
|
||||||
|
const initial = box.getBoundingClientRect();
|
||||||
|
box.dispatchEvent(new PointerEvent('pointerdown', {
|
||||||
|
bubbles: true, pointerId: 42, button: 0, buttons: 1,
|
||||||
|
clientX: initial.left + initial.width / 2, clientY: initial.top + initial.height / 2
|
||||||
|
}));
|
||||||
|
overlay.dispatchEvent(new PointerEvent('pointermove', {
|
||||||
|
bubbles: true, pointerId: 42, buttons: 1,
|
||||||
|
clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
|
||||||
|
}));
|
||||||
|
overlay.dispatchEvent(new PointerEvent('pointerup', {
|
||||||
|
bubbles: true, pointerId: 42, button: 0,
|
||||||
|
clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
|
||||||
|
}));
|
||||||
|
const moved = box.getBoundingClientRect();
|
||||||
|
const handle = box.querySelector('.handle-se');
|
||||||
|
handle.dispatchEvent(new PointerEvent('pointerdown', {
|
||||||
|
bubbles: true, pointerId: 43, button: 0, buttons: 1,
|
||||||
|
clientX: moved.right, clientY: moved.bottom
|
||||||
|
}));
|
||||||
|
overlay.dispatchEvent(new PointerEvent('pointermove', {
|
||||||
|
bubbles: true, pointerId: 43, buttons: 1,
|
||||||
|
clientX: moved.right + 20, clientY: moved.bottom + 16
|
||||||
|
}));
|
||||||
|
overlay.dispatchEvent(new PointerEvent('pointerup', {
|
||||||
|
bubbles: true, pointerId: 43, button: 0,
|
||||||
|
clientX: moved.right + 20, clientY: moved.bottom + 16
|
||||||
|
}));
|
||||||
|
const resized = box.getBoundingClientRect();
|
||||||
|
return !overlay.querySelector('.visual-select-actions').classList.contains('hidden')
|
||||||
|
&& overlay.querySelectorAll('.visual-select-handle').length === 4
|
||||||
|
&& moved.left > initial.left
|
||||||
|
&& moved.top > initial.top
|
||||||
|
&& resized.width > moved.width
|
||||||
|
&& resized.height > moved.height;
|
||||||
|
})()`);
|
||||||
|
chk('框选区域支持创建、移动及四角调整', selectionReady);
|
||||||
|
await js("document.querySelector('.visual-select-actions .tb-btn').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const regionVisual = await js(`(() => ({
|
||||||
|
visible: !document.getElementById('aiVisualCard').classList.contains('hidden'),
|
||||||
|
label: document.getElementById('aiVisualLabel').textContent,
|
||||||
|
meta: document.getElementById('aiVisualMeta').textContent,
|
||||||
|
overlayGone: !document.querySelector('.visual-select-overlay')
|
||||||
|
}))()`);
|
||||||
|
chk('确认框选后恢复 AI 面板并显示区域预览',
|
||||||
|
regionVisual.visible && regionVisual.label === '框选区域' && regionVisual.overlayGone);
|
||||||
|
await js("document.getElementById('aiQuestion').value='这个框选区域是什么';document.getElementById('aiSendBtn').click();");
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
await js("document.getElementById('aiConfirmSendBtn').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
|
const regionContent = received[2] && received[2].messages && received[2].messages.at(-1).content;
|
||||||
|
const regionImage = Array.isArray(regionContent)
|
||||||
|
? regionContent.find((part) => part && part.type === 'image_url')
|
||||||
|
: null;
|
||||||
|
chk('框选区域作为单张图像上下文发送', received.length === 3
|
||||||
|
&& /^data:image\/jpeg;base64,/.test(regionImage?.image_url?.url || ''));
|
||||||
|
|
||||||
|
// 同一会话的第三轮:前两轮必须作为历史外发,且历史里不能夹带图像
|
||||||
|
const regionMessages = (received[2] && received[2].messages) || [];
|
||||||
|
chk('多轮对话把前几轮问答作为历史发送',
|
||||||
|
regionMessages.length >= 4
|
||||||
|
&& regionMessages[0].role === 'system'
|
||||||
|
&& regionMessages.at(-1).role === 'user'
|
||||||
|
&& regionMessages.slice(1, -1).some((m) => m.role === 'assistant'),
|
||||||
|
regionMessages.map((m) => m.role).join(','));
|
||||||
|
chk('历史消息只带文本,不重复上传图像',
|
||||||
|
regionMessages.slice(0, -1).every((m) => typeof m.content === 'string'),
|
||||||
|
regionMessages.map((m) => (typeof m.content === 'string' ? 'str' : 'arr')).join(','));
|
||||||
|
|
||||||
|
// 会话必须落盘:关掉窗口再开回来,历史消息与会话列表都应原样恢复
|
||||||
|
const diskSessions = require(path.join(ROOT, 'src', 'reader', 'ai-sessions'));
|
||||||
|
const storedList = diskSessions.list({ entryId: e.id });
|
||||||
|
const storedId = storedList[0] && storedList[0].id;
|
||||||
|
const storedMessages = storedId ? diskSessions.messages(storedId, { limit: 100 }).messages : [];
|
||||||
|
chk('多轮问答持久化到磁盘会话',
|
||||||
|
storedList.length === 1 && storedMessages.length === 6
|
||||||
|
&& storedMessages.filter((m) => m.role === 'user').length === 3
|
||||||
|
&& storedMessages.filter((m) => m.role === 'assistant').length === 3,
|
||||||
|
`会话=${storedList.length} 消息=${storedMessages.length}`);
|
||||||
|
// 存的是用户看见的那句提问,不是整篇正文,否则重开后气泡会变成十几万字原文
|
||||||
|
chk('会话只存提问本身,正文以 contextRef 摘要记录',
|
||||||
|
storedMessages[0].role === 'user'
|
||||||
|
&& storedMessages[0].text === '这章讲了什么'
|
||||||
|
&& storedMessages[0].contextRef?.scope === 'document'
|
||||||
|
&& storedMessages[0].contextRef.chars >= docChars
|
||||||
|
&& storedMessages[0].contextRef.hash.length === 32,
|
||||||
|
`${storedMessages[0].text.slice(0, 20)} / ${storedMessages[0].contextRef?.chars}`);
|
||||||
|
chk('历史图像以 imageId 引用而非内联 base64',
|
||||||
|
storedMessages.filter((m) => m.images.length).every((m) => m.images.every(
|
||||||
|
(img) => /^img_[0-9a-f]{64}$/.test(img.imageId) && img.base64 === undefined
|
||||||
|
)),
|
||||||
|
JSON.stringify(storedMessages.map((m) => m.images.map((i) => i.imageId.slice(0, 12)))));
|
||||||
|
|
||||||
|
async function waitForNoteCount(count) {
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
let notes = [];
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
notes = readerStore.listNotes({ entryId: e.id });
|
||||||
|
if (notes.length >= count) return notes;
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForSaveModal = () => js(`new Promise((resolve) => {
|
||||||
|
const deadline = Date.now() + 3000;
|
||||||
|
const check = () => {
|
||||||
|
if (!document.getElementById('aiSessionSaveModal').classList.contains('hidden')) {
|
||||||
|
resolve(true);
|
||||||
|
} else if (Date.now() >= deadline) {
|
||||||
|
resolve(false);
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
})`);
|
||||||
|
|
||||||
|
const notesBeforeSave = readerStore.listNotes({ entryId: e.id });
|
||||||
|
const noteIdsBeforeSave = new Set(notesBeforeSave.map((note) => note.id));
|
||||||
|
await js("document.getElementById('aiSaveBtn').click()");
|
||||||
|
await waitForSaveModal();
|
||||||
|
await js(`(() => {
|
||||||
|
const recent = document.getElementById('aiSessionSaveRecent');
|
||||||
|
const rounds = document.getElementById('aiSessionSaveRounds');
|
||||||
|
recent.click();
|
||||||
|
rounds.value = '2';
|
||||||
|
rounds.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
document.getElementById('aiSessionSaveConfirmBtn').click();
|
||||||
|
})()`);
|
||||||
|
const notesAfterRecent = await waitForNoteCount(notesBeforeSave.length + 1);
|
||||||
|
const recentNote = notesAfterRecent.find((note) => !noteIdsBeforeSave.has(note.id));
|
||||||
|
const recentText = String(recentNote?.text || '');
|
||||||
|
const recentSecondQuestion = recentText.indexOf('这张页面图像讲了什么');
|
||||||
|
const recentSecondAnswer = recentText.indexOf(AI_MARKDOWN, recentSecondQuestion);
|
||||||
|
const recentThirdQuestion = recentText.indexOf('这个框选区域是什么', recentSecondAnswer);
|
||||||
|
const recentThirdAnswer = recentText.indexOf(AI_MARKDOWN, recentThirdQuestion);
|
||||||
|
chk('保存最近两轮只新增一条 AI 会话笔记',
|
||||||
|
notesAfterRecent.length === notesBeforeSave.length + 1
|
||||||
|
&& recentNote?.source === 'ai'
|
||||||
|
&& recentNote?.title === storedList[0].title,
|
||||||
|
`新增=${notesAfterRecent.length - notesBeforeSave.length} 来源=${recentNote?.source} 标题=${recentNote?.title}`);
|
||||||
|
chk('最近两轮笔记正文排除第一轮并保持问答顺序',
|
||||||
|
!recentText.includes('这章讲了什么')
|
||||||
|
&& recentSecondQuestion >= 0
|
||||||
|
&& recentSecondAnswer > recentSecondQuestion
|
||||||
|
&& recentThirdQuestion > recentSecondAnswer
|
||||||
|
&& recentThirdAnswer > recentThirdQuestion,
|
||||||
|
recentText.slice(0, 160));
|
||||||
|
const recentPreference = settings.get('reader.aiSessionSave', null);
|
||||||
|
chk('保存最近两轮偏好已持久化',
|
||||||
|
recentPreference?.mode === 'recent' && recentPreference?.rounds === 2,
|
||||||
|
JSON.stringify(recentPreference));
|
||||||
|
|
||||||
|
const recentNoteIds = new Set(notesAfterRecent.map((note) => note.id));
|
||||||
|
await js("document.getElementById('aiSaveBtn').click()");
|
||||||
|
await waitForSaveModal();
|
||||||
|
await js(`(() => {
|
||||||
|
document.getElementById('aiSessionSaveAll').click();
|
||||||
|
document.getElementById('aiSessionSaveConfirmBtn').click();
|
||||||
|
})()`);
|
||||||
|
const notesAfterAll = await waitForNoteCount(notesAfterRecent.length + 1);
|
||||||
|
const allNote = notesAfterAll.find((note) => !recentNoteIds.has(note.id));
|
||||||
|
const allText = String(allNote?.text || '');
|
||||||
|
chk('保存全部三轮再次只新增一条会话笔记',
|
||||||
|
notesAfterAll.length === notesAfterRecent.length + 1
|
||||||
|
&& allNote?.source === 'ai'
|
||||||
|
&& allNote?.title === storedList[0].title
|
||||||
|
&& allText.includes('这章讲了什么')
|
||||||
|
&& allText.includes('这张页面图像讲了什么')
|
||||||
|
&& allText.includes('这个框选区域是什么'),
|
||||||
|
`新增=${notesAfterAll.length - notesAfterRecent.length} 标题=${allNote?.title}`);
|
||||||
|
|
||||||
|
const reopened = new BrowserWindow({
|
||||||
|
show: false, width: 1200, height: 860,
|
||||||
|
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
|
||||||
|
});
|
||||||
|
await reopened.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
|
||||||
|
await new Promise((r) => setTimeout(r, 9000));
|
||||||
|
const reopenedJs = (code) => reopened.webContents.executeJavaScript(code);
|
||||||
|
await reopenedJs("document.querySelector('[data-pane=\"ai\"]').click()");
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const restored = await reopenedJs(`(() => ({
|
||||||
|
bubbles: document.querySelectorAll('#aiOutput .ai-msg').length,
|
||||||
|
first: (document.querySelector('#aiOutput .ai-msg') || {}).textContent || '',
|
||||||
|
options: document.getElementById('aiSessionSelect').options.length
|
||||||
|
}))()`);
|
||||||
|
chk('重开阅读器恢复会话与历史气泡',
|
||||||
|
restored.bubbles === 6 && restored.options === 1 && restored.first.includes('这章讲了什么'),
|
||||||
|
`气泡=${restored.bubbles} 会话=${restored.options}`);
|
||||||
|
const requestsBeforeReopen = received.length;
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
chk('恢复历史不会重新调用模型', received.length === requestsBeforeReopen,
|
||||||
|
`${requestsBeforeReopen} -> ${received.length}`);
|
||||||
|
reopened.destroy();
|
||||||
|
|
||||||
|
chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
|
||||||
|
const output = document.getElementById('aiOutput');
|
||||||
|
const text = 'x'.repeat(256 * 1024 + 1);
|
||||||
|
window.AiMarkdown.mount(output, text);
|
||||||
|
return output.classList.contains('ai-output-plain')
|
||||||
|
&& output.textContent.length === text.length
|
||||||
|
&& output.children.length === 0;
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
const saved = await js("window.api.settings.get('reader.aiScope','selection').then(r=>r.data)");
|
||||||
|
chk('范围选择已持久化', saved === 'region-image', String(saved));
|
||||||
|
|
||||||
|
// 旧版本存过 chapter,升级后必须迁移到 document,而不是回落成 selection
|
||||||
|
await js("window.api.settings.set('reader.aiScope','chapter')");
|
||||||
|
const migrationWin = new BrowserWindow({
|
||||||
|
show: false, width: 1200, height: 860,
|
||||||
|
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
|
||||||
|
});
|
||||||
|
await migrationWin.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
|
||||||
|
await new Promise((r) => setTimeout(r, 9000));
|
||||||
|
const migratedValue = await migrationWin.webContents.executeJavaScript("document.getElementById('aiScope').value");
|
||||||
|
const migratedSaved = await migrationWin.webContents.executeJavaScript(
|
||||||
|
"window.api.settings.get('reader.aiScope','selection').then(r=>r.data)"
|
||||||
|
);
|
||||||
|
chk('旧 chapter 设置迁移为全文', migratedValue === 'document' && migratedSaved === 'document',
|
||||||
|
`${migratedValue}/${migratedSaved}`);
|
||||||
|
migrationWin.destroy();
|
||||||
|
|
||||||
|
const regionDataUrl = regionImage?.image_url?.url || '';
|
||||||
|
const encoded = regionDataUrl.slice(regionDataUrl.indexOf(',') + 1);
|
||||||
|
const imageBytes = Buffer.from(encoded, 'base64');
|
||||||
|
const imageSize = nativeImage.createFromBuffer(imageBytes).getSize();
|
||||||
|
const visualContext = {
|
||||||
|
kind: 'region',
|
||||||
|
includeImage: true,
|
||||||
|
image: {
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
base64: encoded,
|
||||||
|
width: imageSize.width,
|
||||||
|
height: imageSize.height,
|
||||||
|
bytes: imageBytes.length
|
||||||
|
},
|
||||||
|
ocr: { status: 'idle', text: '', include: false }
|
||||||
|
};
|
||||||
|
const aiClient = require(path.join(ROOT, 'src', 'reader', 'ai-client'));
|
||||||
|
|
||||||
|
aiConfig.save({
|
||||||
|
protocol: 'anthropic',
|
||||||
|
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||||
|
model: 'claude-fixture',
|
||||||
|
apiKey: '',
|
||||||
|
vision: true
|
||||||
|
});
|
||||||
|
const anthropicText = await aiClient.stream({
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '测试 Anthropic 图片',
|
||||||
|
visualContexts: [visualContext]
|
||||||
|
});
|
||||||
|
const anthropicRequest = requests.at(-1);
|
||||||
|
const anthropicImage = anthropicRequest?.body?.messages?.[0]?.content?.[1];
|
||||||
|
chk('Anthropic Messages API 真实请求使用 base64 source',
|
||||||
|
anthropicText === 'Anthropic 正常'
|
||||||
|
&& anthropicRequest?.url === '/v1/messages'
|
||||||
|
&& anthropicRequest?.headers?.['anthropic-version'] === '2023-06-01'
|
||||||
|
&& anthropicImage?.type === 'image'
|
||||||
|
&& anthropicImage?.source?.type === 'base64'
|
||||||
|
&& anthropicImage?.source?.media_type === 'image/jpeg');
|
||||||
|
|
||||||
|
aiConfig.save({
|
||||||
|
protocol: 'openai-responses',
|
||||||
|
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||||
|
model: 'responses-fixture',
|
||||||
|
apiKey: '',
|
||||||
|
vision: true
|
||||||
|
});
|
||||||
|
const responsesText = await aiClient.stream({
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '测试 Responses 图片',
|
||||||
|
visualContexts: [visualContext]
|
||||||
|
});
|
||||||
|
const responsesRequest = requests.at(-1);
|
||||||
|
const responsesImage = responsesRequest?.body?.input?.[0]?.content?.[1];
|
||||||
|
chk('OpenAI Responses API 真实请求使用 input_image',
|
||||||
|
responsesText === 'Responses 正常'
|
||||||
|
&& responsesRequest?.url === '/v1/responses'
|
||||||
|
&& responsesImage?.type === 'input_image'
|
||||||
|
&& /^data:image\/jpeg;base64,/.test(responsesImage?.image_url || ''));
|
||||||
|
|
||||||
|
chk('无渲染层报错', errs.length === 0, errs.slice(0, 2).join(' | '));
|
||||||
|
|
||||||
|
console.log('\n========== AI 上下文控制验证 ==========');
|
||||||
|
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
|
||||||
|
const bad = results.filter((r) => r[0] === 'FAIL').length;
|
||||||
|
console.log(`\n通过 ${results.length - bad}/${results.length}`);
|
||||||
|
server.close();
|
||||||
|
app.exit(bad ? 1 : 0);
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error('异常:', e);
|
||||||
|
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
const { app, BrowserWindow, safeStorage } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-annotation-ui-'));
|
||||||
|
const PDF_CACHE = path.join(os.tmpdir(), 'peoplelib-fixtures', 'dummy.pdf');
|
||||||
|
app.setPath('userData', TMP);
|
||||||
|
app.setPath('appData', TMP);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function check(name, condition, detail = '') {
|
||||||
|
results.push([condition ? 'OK' : 'FAIL', name, detail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wait(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理只在设了 HTTPS_PROXY 时用。写死本机代理会让没有代理的环境(CI)
|
||||||
|
// 直接 ECONNREFUSED,取不到夹具。
|
||||||
|
async function ensurePdf() {
|
||||||
|
if (fs.existsSync(PDF_CACHE)) return;
|
||||||
|
fs.mkdirSync(path.dirname(PDF_CACHE), { recursive: true });
|
||||||
|
const { fetch, ProxyAgent } = require('undici');
|
||||||
|
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || '';
|
||||||
|
const response = await fetch('https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', {
|
||||||
|
dispatcher: proxy ? new ProxyAgent({ uri: proxy, connectTimeout: 30000 }) : undefined
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`PDF 下载失败:${response.status}`);
|
||||||
|
fs.writeFileSync(PDF_CACHE, Buffer.from(await response.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReader(entryId) {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(ROOT, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const errors = [];
|
||||||
|
win.webContents.on('console-message', (event) => {
|
||||||
|
const { level, message } = event;
|
||||||
|
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
|
||||||
|
errors.push(message);
|
||||||
|
console.error('RENDERER:', message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), {
|
||||||
|
query: { entryId }
|
||||||
|
});
|
||||||
|
await wait(7000);
|
||||||
|
return { win, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function js(win, source) {
|
||||||
|
try {
|
||||||
|
return await win.webContents.executeJavaScript(source);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('脚本失败:', source.slice(0, 180), error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tool(win, name) {
|
||||||
|
await js(win, `document.querySelector('[data-annotation-tool="${name}"]').click()`);
|
||||||
|
await wait(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drag(win, x1, y1, x2, y2) {
|
||||||
|
await js(win, `(() => {
|
||||||
|
const canvas = document.querySelector('.pdfx-annotation .upper-canvas');
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const fire = (type, x, y, buttons) => canvas.dispatchEvent(new MouseEvent(type, {
|
||||||
|
bubbles: true, cancelable: true, button: 0, buttons,
|
||||||
|
clientX: rect.left + x, clientY: rect.top + y
|
||||||
|
}));
|
||||||
|
fire('mousedown', ${x1}, ${y1}, 1);
|
||||||
|
fire('mousemove', ${x2}, ${y2}, 1);
|
||||||
|
fire('mouseup', ${x2}, ${y2}, 0);
|
||||||
|
})()`);
|
||||||
|
await wait(350);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickCanvas(win, x, y) {
|
||||||
|
await drag(win, x, y, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fireTouch(win, type, points, changedPoints = points) {
|
||||||
|
return js(win, `(() => {
|
||||||
|
const target = document.querySelector('.pdfx-annotation .upper-canvas');
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const make = (point) => new Touch({
|
||||||
|
identifier: point.id,
|
||||||
|
target,
|
||||||
|
clientX: rect.left + point.x,
|
||||||
|
clientY: rect.top + point.y,
|
||||||
|
screenX: rect.left + point.x,
|
||||||
|
screenY: rect.top + point.y,
|
||||||
|
pageX: rect.left + point.x,
|
||||||
|
pageY: rect.top + point.y,
|
||||||
|
radiusX: 2,
|
||||||
|
radiusY: 2,
|
||||||
|
force: 1
|
||||||
|
});
|
||||||
|
const touches = ${JSON.stringify(points)}.map(make);
|
||||||
|
const changedTouches = ${JSON.stringify(changedPoints)}.map(make);
|
||||||
|
const event = new TouchEvent(${JSON.stringify(type)}, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
touches,
|
||||||
|
targetTouches: touches,
|
||||||
|
changedTouches
|
||||||
|
});
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
return event.defaultPrevented;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await ensurePdf();
|
||||||
|
require(path.join(ROOT, 'main.js'));
|
||||||
|
|
||||||
|
const settings = require(path.join(ROOT, 'src', 'settings'));
|
||||||
|
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
||||||
|
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
|
||||||
|
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
||||||
|
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||||||
|
settings.init(TMP);
|
||||||
|
readerStore.init(TMP);
|
||||||
|
annotations.init(TMP);
|
||||||
|
aiConfig.init(TMP, safeStorage);
|
||||||
|
library.init(path.join(TMP, 'library'));
|
||||||
|
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
|
||||||
|
|
||||||
|
const entry = library.add({
|
||||||
|
title: 'Annotation Fixture',
|
||||||
|
authors: [],
|
||||||
|
files: [{ path: PDF_CACHE, name: 'dummy.pdf', format: 'PDF' }]
|
||||||
|
});
|
||||||
|
await wait(1500);
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) window.hide();
|
||||||
|
|
||||||
|
const first = await openReader(entry.id);
|
||||||
|
const win = first.win;
|
||||||
|
check('PDF 页面成功渲染', await js(win, "!!document.querySelector('.pdfx-page .pdfx-canvas')"));
|
||||||
|
check('批注入口仅在 PDF 中显示', !(await js(win, "document.getElementById('annotationToggleBtn').classList.contains('hidden')")));
|
||||||
|
check('右上角提供界面主题按钮', await js(win, `(() => {
|
||||||
|
const button = document.getElementById('uiThemeBtn');
|
||||||
|
return !!button && !!button.querySelector('svg') && button.title === '切换到明亮主题';
|
||||||
|
})()`));
|
||||||
|
check('阅读器默认使用暗色界面', (await js(win, "document.documentElement.dataset.uiTheme")) === 'dark');
|
||||||
|
const documentTheme = await js(win, "document.getElementById('themeSelect').value");
|
||||||
|
await js(win, "document.getElementById('uiThemeBtn').click()");
|
||||||
|
await wait(300);
|
||||||
|
check('主题按钮可切换为明亮界面', await js(win, `document.documentElement.dataset.uiTheme === 'light'
|
||||||
|
&& document.getElementById('uiThemeBtn').title === '切换到暗色主题'
|
||||||
|
&& getComputedStyle(document.body).color === 'rgb(31, 41, 55)'`));
|
||||||
|
check('界面主题不改变文档阅读主题',
|
||||||
|
(await js(win, "document.getElementById('themeSelect').value")) === documentTheme);
|
||||||
|
check('界面主题选择已持久化', settings.get('reader.uiTheme', 'dark') === 'light');
|
||||||
|
|
||||||
|
await js(win, "document.getElementById('annotationToggleBtn').click()");
|
||||||
|
await wait(300);
|
||||||
|
check('批注工具栏可展开', !(await js(win, "document.getElementById('annotationToolbar').classList.contains('hidden')")));
|
||||||
|
check('完整工具齐全', (await js(win, "document.querySelectorAll('[data-annotation-tool]').length")) === 8);
|
||||||
|
check('批注工具使用纯图标并提供悬浮提示', await js(win, `Array.from(
|
||||||
|
document.querySelectorAll('[data-annotation-tool]')
|
||||||
|
).every(button => button.querySelector('svg') && !button.textContent.trim()
|
||||||
|
&& button.title && button.getAttribute('aria-label'))`));
|
||||||
|
check('撤销、重做、清空与入口均使用提示图标', await js(win, `[
|
||||||
|
'annotationUndoBtn','annotationRedoBtn','annotationClearBtn','annotationToggleBtn'
|
||||||
|
].every(id => {
|
||||||
|
const button = document.getElementById(id);
|
||||||
|
return button.querySelector('svg') && !button.textContent.trim()
|
||||||
|
&& button.title && button.getAttribute('aria-label');
|
||||||
|
})`));
|
||||||
|
check('手形工具默认启用且不遮挡页面',
|
||||||
|
await js(win, `document.querySelector('[data-annotation-tool="pan"]').classList.contains('active')
|
||||||
|
&& document.querySelector('.pdfx-scroller').classList.contains('pdfx-tool-pan')
|
||||||
|
&& getComputedStyle(document.querySelector('.pdfx-annotation')).pointerEvents === 'none'
|
||||||
|
&& getComputedStyle(document.querySelector('.pdfx-text span')).userSelect === 'none'`));
|
||||||
|
const panResult = await js(win, `(() => {
|
||||||
|
const scroller = document.querySelector('.pdfx-scroller');
|
||||||
|
const page = document.querySelector('.pdfx-page');
|
||||||
|
scroller.scrollTop = Math.min(180, scroller.scrollHeight - scroller.clientHeight);
|
||||||
|
const before = scroller.scrollTop;
|
||||||
|
const rect = page.getBoundingClientRect();
|
||||||
|
const fire = (target, type, x, y, buttons) => target.dispatchEvent(new PointerEvent(type, {
|
||||||
|
bubbles: true, cancelable: true, pointerId: 17, pointerType: 'mouse',
|
||||||
|
button: 0, buttons, clientX: rect.left + x, clientY: rect.top + y
|
||||||
|
}));
|
||||||
|
fire(page, 'pointerdown', 200, 300, 1);
|
||||||
|
fire(scroller, 'pointermove', 200, 360, 1);
|
||||||
|
fire(scroller, 'pointerup', 200, 360, 0);
|
||||||
|
return { before, after: scroller.scrollTop };
|
||||||
|
})()`);
|
||||||
|
check('手形工具可拖拽 PDF 页面', panResult.after < panResult.before,
|
||||||
|
`${panResult.before} -> ${panResult.after}`);
|
||||||
|
await tool(win, 'text-select');
|
||||||
|
check('文本指针工具恢复正文选择且使用独立图标',
|
||||||
|
await js(win, `document.querySelector('[data-annotation-tool="text-select"]').classList.contains('active')
|
||||||
|
&& document.querySelector('.pdfx-scroller').classList.contains('pdfx-tool-text-select')
|
||||||
|
&& getComputedStyle(document.querySelector('.pdfx-text span')).userSelect === 'text'`));
|
||||||
|
|
||||||
|
await tool(win, 'rectangle');
|
||||||
|
await drag(win, 100, 100, 250, 190);
|
||||||
|
check('矩形工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('1 项'));
|
||||||
|
|
||||||
|
await js(win, "document.getElementById('annotationColor').value='#00aa00';document.getElementById('annotationColor').dispatchEvent(new Event('change'))");
|
||||||
|
await js(win, "document.getElementById('annotationWidth').value='5';document.getElementById('annotationWidth').dispatchEvent(new Event('change'))");
|
||||||
|
await tool(win, 'pen');
|
||||||
|
await drag(win, 120, 240, 280, 280);
|
||||||
|
check('画笔工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('2 项'));
|
||||||
|
|
||||||
|
await tool(win, 'highlight');
|
||||||
|
await drag(win, 140, 320, 330, 320);
|
||||||
|
check('高亮工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
|
||||||
|
|
||||||
|
await tool(win, 'text');
|
||||||
|
await clickCanvas(win, 340, 130);
|
||||||
|
win.webContents.insertText('批注文本');
|
||||||
|
await wait(200);
|
||||||
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||||
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
|
||||||
|
await wait(500);
|
||||||
|
check('文本工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
|
||||||
|
|
||||||
|
await tool(win, 'select');
|
||||||
|
await clickCanvas(win, 102, 102);
|
||||||
|
await js(win, "document.getElementById('progressRange').dispatchEvent(new Event('change'))");
|
||||||
|
await wait(500);
|
||||||
|
const beforeStyleSync = annotations.get(entry.id, annotations.documentKey(PDF_CACHE)).pages['1'].objects;
|
||||||
|
check('状态刷新不会误改旧选中批注的样式',
|
||||||
|
beforeStyleSync.some((object) => object.annotationKind === 'rectangle' && object.stroke === '#ff4d4f'));
|
||||||
|
await drag(win, 350, 140, 390, 170);
|
||||||
|
check('选择工具可移动批注', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
|
||||||
|
|
||||||
|
await tool(win, 'eraser');
|
||||||
|
await clickCanvas(win, 102, 102);
|
||||||
|
check('橡皮工具删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
|
||||||
|
|
||||||
|
await js(win, "document.getElementById('annotationUndoBtn').click()");
|
||||||
|
await wait(400);
|
||||||
|
check('撤销恢复删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
|
||||||
|
await js(win, "document.getElementById('annotationRedoBtn').click()");
|
||||||
|
await wait(400);
|
||||||
|
check('重做再次删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
|
||||||
|
|
||||||
|
const beforeWidth = await js(win, "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
|
||||||
|
await js(win, "document.getElementById('zoomInBtn').click()");
|
||||||
|
await wait(2500);
|
||||||
|
const afterWidth = await js(win, "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
|
||||||
|
check('缩放后批注层同步缩放', afterWidth > beforeWidth, `${beforeWidth} -> ${afterWidth}`);
|
||||||
|
check('缩放后撤销历史仍保留', !(await js(win, "document.getElementById('annotationUndoBtn').disabled")));
|
||||||
|
|
||||||
|
await wait(1000);
|
||||||
|
const stored = annotations.get(entry.id, annotations.documentKey(PDF_CACHE));
|
||||||
|
const objects = stored.pages['1'] && stored.pages['1'].objects;
|
||||||
|
check('批注写入 data 对应文件', Array.isArray(objects) && objects.length === 3, `对象=${objects && objects.length}`);
|
||||||
|
check('画笔、高亮和文本类型被持久化',
|
||||||
|
['pen', 'highlight', 'text'].every((kind) => objects.some((object) => object.annotationKind === kind)));
|
||||||
|
check('编辑后的文本内容被持久化',
|
||||||
|
objects.some((object) => object.annotationKind === 'text' && object.text === '批注文本'));
|
||||||
|
check('颜色与粗细设置写入新批注',
|
||||||
|
objects.some((object) => object.annotationKind === 'pen' && object.stroke === '#00aa00' && object.strokeWidth === 5));
|
||||||
|
const annotationFile = path.join(TMP, 'reader-annotations', `${entry.id}.json`);
|
||||||
|
check('批注文件位于 reader-annotations 目录', fs.existsSync(annotationFile), annotationFile);
|
||||||
|
|
||||||
|
await tool(win, 'text');
|
||||||
|
await clickCanvas(win, 460, 210);
|
||||||
|
win.webContents.insertText('立即关闭也保存');
|
||||||
|
win.close();
|
||||||
|
await wait(900);
|
||||||
|
const second = await openReader(entry.id);
|
||||||
|
check('重开阅读器后恢复明亮界面',
|
||||||
|
(await js(second.win, "document.documentElement.dataset.uiTheme")) === 'light');
|
||||||
|
await js(second.win, "document.getElementById('annotationToggleBtn').click()");
|
||||||
|
await wait(500);
|
||||||
|
check('编辑文本后立即关闭仍保存最后状态',
|
||||||
|
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
|
||||||
|
await js(second.win, "document.getElementById('annotationClearBtn').click()");
|
||||||
|
await wait(200);
|
||||||
|
check('清空本页使用应用内确认框',
|
||||||
|
!(await js(second.win, "document.getElementById('annotationClearModal').classList.contains('hidden')")));
|
||||||
|
await js(second.win, "document.getElementById('annotationClearCancelBtn').click()");
|
||||||
|
check('取消清空保留全部批注',
|
||||||
|
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
|
||||||
|
await js(second.win, "document.getElementById('annotationClearBtn').click();document.getElementById('annotationClearConfirmBtn').click()");
|
||||||
|
await wait(350);
|
||||||
|
check('确认清空删除当前页批注',
|
||||||
|
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('0 项'));
|
||||||
|
await js(second.win, "document.getElementById('annotationUndoBtn').click()");
|
||||||
|
await wait(350);
|
||||||
|
check('清空后可撤销恢复',
|
||||||
|
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
|
||||||
|
await js(second.win, "document.querySelector('[data-pane=\"annotations\"]').click()");
|
||||||
|
check('标注页签列出已标注页面', await js(second.win, `(() => {
|
||||||
|
const row = document.querySelector('#annotationList .list-item');
|
||||||
|
return !!row && row.textContent.includes('第 1 页') && row.textContent.includes('4 项标注');
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
await js(second.win, `document.querySelector('[data-pane="notes"]').click();
|
||||||
|
document.getElementById('addNoteBtn').click();
|
||||||
|
document.querySelector('#noteTypeChooser [data-note-type="reading"]').click();
|
||||||
|
document.getElementById('noteTitleInput').value = '人工笔记';
|
||||||
|
Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
|
||||||
|
.setText('通过阅读器直接记录');
|
||||||
|
document.getElementById('noteTagsInput').value = '集成, 手工';
|
||||||
|
document.getElementById('noteEditorSaveBtn').click()`);
|
||||||
|
await wait(500);
|
||||||
|
const manualNotes = readerStore.getState(entry.id).notes;
|
||||||
|
check('阅读器可直接新建结构化人工笔记',
|
||||||
|
manualNotes.some((note) => note.source === 'manual'
|
||||||
|
&& note.title === '人工笔记'
|
||||||
|
&& note.tags.includes('集成')));
|
||||||
|
|
||||||
|
await tool(second.win, 'text-select');
|
||||||
|
const selectionText = await js(second.win, `(() => {
|
||||||
|
const span = Array.from(document.querySelectorAll('.pdfx-text span'))
|
||||||
|
.find((node) => node.textContent.trim());
|
||||||
|
if (!span) return '';
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(span);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||||
|
return selection.toString().trim();
|
||||||
|
})()`);
|
||||||
|
await wait(100);
|
||||||
|
check('正文划选显示摘录与记笔记操作',
|
||||||
|
!!selectionText && await js(second.win, `!document.getElementById('selBar').classList.contains('hidden')
|
||||||
|
&& !!document.querySelector('[data-sel="excerpt"]')
|
||||||
|
&& !!document.querySelector('[data-sel="note"]')`));
|
||||||
|
await js(second.win, `document.querySelector('[data-sel="excerpt"]').click()`);
|
||||||
|
await wait(500);
|
||||||
|
check('摘录保留正文引用和精确位置',
|
||||||
|
readerStore.getState(entry.id).notes.some((note) => note.source === 'selection'
|
||||||
|
&& note.quote.includes(selectionText) && note.locator && note.locator.page === 1));
|
||||||
|
|
||||||
|
await tool(second.win, 'pen');
|
||||||
|
const touchBase = 4;
|
||||||
|
await fireTouch(second.win, 'touchstart', [{ id: 1, x: 120, y: 380 }]);
|
||||||
|
await fireTouch(second.win, 'touchmove', [{ id: 1, x: 210, y: 410 }]);
|
||||||
|
await fireTouch(second.win, 'touchend', [], [{ id: 1, x: 210, y: 410 }]);
|
||||||
|
await wait(500);
|
||||||
|
check('单指触摸仍可完成画笔批注',
|
||||||
|
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1} 项`));
|
||||||
|
|
||||||
|
const beforePinchWidth = await js(second.win,
|
||||||
|
"document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
|
||||||
|
await fireTouch(second.win, 'touchstart', [{ id: 11, x: 150, y: 460 }]);
|
||||||
|
await fireTouch(second.win, 'touchmove', [{ id: 11, x: 210, y: 480 }]);
|
||||||
|
await fireTouch(second.win, 'touchstart', [
|
||||||
|
{ id: 11, x: 210, y: 480 },
|
||||||
|
{ id: 12, x: 310, y: 480 }
|
||||||
|
], [{ id: 12, x: 310, y: 480 }]);
|
||||||
|
await fireTouch(second.win, 'touchmove', [
|
||||||
|
{ id: 11, x: 190, y: 480 },
|
||||||
|
{ id: 12, x: 330, y: 480 }
|
||||||
|
]);
|
||||||
|
check('双指缩放提供即时预览',
|
||||||
|
await js(second.win, "document.querySelector('.host-pdf').classList.contains('pinch-preview')"));
|
||||||
|
await fireTouch(second.win, 'touchend', [
|
||||||
|
{ id: 11, x: 190, y: 480 }
|
||||||
|
], [{ id: 12, x: 330, y: 480 }]);
|
||||||
|
await fireTouch(second.win, 'touchend', [], [{ id: 11, x: 190, y: 480 }]);
|
||||||
|
await wait(2600);
|
||||||
|
const afterPinchWidth = await js(second.win,
|
||||||
|
"document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
|
||||||
|
check('PDF 双指缩放提交新比例并保持焦点页',
|
||||||
|
afterPinchWidth > beforePinchWidth
|
||||||
|
&& (await js(second.win, "document.getElementById('posLabel').textContent")) === '第 1 页',
|
||||||
|
`${beforePinchWidth} -> ${afterPinchWidth}`);
|
||||||
|
check('第二指介入回滚未完成笔画',
|
||||||
|
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1} 项`));
|
||||||
|
check('双指缩放后可见页面不会变成黑色画布', await js(second.win, `(() => {
|
||||||
|
const visible = Array.from(document.querySelectorAll('.pdfx-page')).filter((page) => {
|
||||||
|
const rect = page.getBoundingClientRect();
|
||||||
|
return rect.bottom > 0 && rect.top < innerHeight;
|
||||||
|
});
|
||||||
|
return visible.length > 0 && visible.every((page) => {
|
||||||
|
const canvas = page.querySelector('.pdfx-canvas');
|
||||||
|
if (!canvas || canvas.width < 2 || canvas.height < 2) return false;
|
||||||
|
const pixel = canvas.getContext('2d').getImageData(
|
||||||
|
Math.floor(canvas.width / 2),
|
||||||
|
Math.floor(canvas.height / 2),
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
).data;
|
||||||
|
return pixel[0] + pixel[1] + pixel[2] > 90;
|
||||||
|
});
|
||||||
|
})()`));
|
||||||
|
await tool(second.win, 'text');
|
||||||
|
await fireTouch(second.win, 'touchstart', [{ id: 21, x: 420, y: 390 }]);
|
||||||
|
await fireTouch(second.win, 'touchstart', [
|
||||||
|
{ id: 21, x: 420, y: 390 },
|
||||||
|
{ id: 22, x: 520, y: 390 }
|
||||||
|
], [{ id: 22, x: 520, y: 390 }]);
|
||||||
|
await fireTouch(second.win, 'touchend', [], [
|
||||||
|
{ id: 21, x: 420, y: 390 },
|
||||||
|
{ id: 22, x: 520, y: 390 }
|
||||||
|
]);
|
||||||
|
await wait(1800);
|
||||||
|
check('文本工具下第二指介入不会误留文字批注',
|
||||||
|
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1} 项`));
|
||||||
|
check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
|
||||||
|
check('重开窗口无渲染错误', second.errors.length === 0, second.errors.slice(0, 2).join(' | '));
|
||||||
|
|
||||||
|
// 书库卡片上的批注计数必须来自真实落盘的批注,而不是渲染层自己数的
|
||||||
|
const storedCount = annotations.getCounts()[String(entry.id)] || 0;
|
||||||
|
check('批注已落盘并可计数', storedCount > 0, `count=${storedCount}`);
|
||||||
|
const libraryWindow = BrowserWindow.getAllWindows()
|
||||||
|
.find((w) => !w.isDestroyed() && /index\.html/.test(w.webContents.getURL()));
|
||||||
|
check('存在书库窗口', !!libraryWindow);
|
||||||
|
if (libraryWindow) {
|
||||||
|
libraryWindow.show();
|
||||||
|
await js(libraryWindow, `(async () => {
|
||||||
|
document.querySelector('[data-tab="library"]').click();
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
document.querySelector('[data-tab="notes"]').click();
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
document.querySelector('[data-tab="library"]').click();
|
||||||
|
await new Promise((r) => setTimeout(r, 1800));
|
||||||
|
})()`);
|
||||||
|
const badge = await js(libraryWindow, `(() => {
|
||||||
|
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
|
||||||
|
if (!card) return { missing: true };
|
||||||
|
const cover = card.querySelector('.card-cover').getBoundingClientRect();
|
||||||
|
const annot = card.querySelector('.card-badge.annotation-count');
|
||||||
|
const end = card.querySelector('.card-cover-badges.end');
|
||||||
|
const start = card.querySelector('.card-cover-badges.start');
|
||||||
|
if (!annot) return { noBadge: true };
|
||||||
|
const ar = annot.getBoundingClientRect();
|
||||||
|
const sr = start.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
text: annot.textContent.trim(),
|
||||||
|
inEnd: end.contains(annot),
|
||||||
|
insideCover: ar.right <= cover.right + 0.5 && ar.bottom <= cover.bottom + 0.5,
|
||||||
|
rightOfStatus: ar.left >= sr.right - 0.5,
|
||||||
|
statusText: start.textContent.trim()
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check('书库卡片显示与落盘一致的批注数',
|
||||||
|
badge.text === `批注 ${storedCount}`, JSON.stringify(badge));
|
||||||
|
check('批注标识在封面右下角,不与左下角状态重叠',
|
||||||
|
badge.inEnd && badge.insideCover && badge.rightOfStatus, JSON.stringify(badge));
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
|
||||||
|
fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
|
||||||
|
|
||||||
|
console.log('\n========== PDF 批注集成验证 ==========');
|
||||||
|
for (const [status, name, detail] of results) {
|
||||||
|
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
|
||||||
|
}
|
||||||
|
const failed = results.filter((result) => result[0] === 'FAIL').length;
|
||||||
|
console.log(`\n通过 ${results.length - failed}/${results.length}`);
|
||||||
|
app.exit(failed ? 1 : 0);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('异常:', error);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
const { app, BrowserWindow, nativeImage } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-cover-ui-'));
|
||||||
|
app.setPath('userData', TMP);
|
||||||
|
app.setPath('appData', TMP);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function check(name, condition, detail = '') {
|
||||||
|
results.push([condition ? 'OK' : 'FAIL', name, detail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePdf(file) {
|
||||||
|
const stream = 'q\n0.12 0.35 0.78 rg\n0 0 400 600 re f\nQ\nBT\n/F1 34 Tf\n1 1 1 rg\n74 300 Td\n(PDF COVER) Tj\nET\n';
|
||||||
|
const objects = [
|
||||||
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||||
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||||
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
|
||||||
|
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}endstream`,
|
||||||
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
|
||||||
|
];
|
||||||
|
let pdf = '%PDF-1.4\n';
|
||||||
|
const offsets = [0];
|
||||||
|
objects.forEach((object, index) => {
|
||||||
|
offsets.push(Buffer.byteLength(pdf));
|
||||||
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||||
|
});
|
||||||
|
const xref = Buffer.byteLength(pdf);
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
for (let index = 1; index <= objects.length; index++) {
|
||||||
|
pdf += `${String(offsets[index]).padStart(10, '0')} 00000 n \n`;
|
||||||
|
}
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||||
|
fs.writeFileSync(file, pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeEpub(file) {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
|
||||||
|
zip.file('META-INF/container.xml', `<?xml version="1.0"?>
|
||||||
|
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
|
||||||
|
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||||
|
</container>`);
|
||||||
|
zip.file('OEBPS/content.opf', `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>EPUB Cover Fixture</dc:title></metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="cover" href="cover.svg" media-type="image/svg+xml" properties="cover-image"/>
|
||||||
|
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine><itemref idref="chapter"/></spine>
|
||||||
|
</package>`);
|
||||||
|
zip.file('OEBPS/cover.svg', `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="360">
|
||||||
|
<rect width="240" height="360" fill="#d43d32"/>
|
||||||
|
<rect x="20" y="20" width="200" height="320" fill="none" stroke="#fff" stroke-width="4"/>
|
||||||
|
</svg>`);
|
||||||
|
zip.file('OEBPS/chapter.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><body>Fixture</body></html>');
|
||||||
|
fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeFirstPageEpub(file) {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
|
||||||
|
zip.file('META-INF/container.xml', `<?xml version="1.0"?>
|
||||||
|
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
|
||||||
|
<rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||||
|
</container>`);
|
||||||
|
zip.file('OPS/book.opf', `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>First Page Fixture</dc:title></metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="art" href="art.svg" media-type="image/svg+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine><itemref idref="title"/></spine>
|
||||||
|
</package>`);
|
||||||
|
zip.file('OPS/title.xhtml', `<html xmlns="http://www.w3.org/1999/xhtml"><body>
|
||||||
|
<img src="art.svg" alt="First page"/>
|
||||||
|
</body></html>`);
|
||||||
|
zip.file('OPS/art.svg', `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="360">
|
||||||
|
<rect width="240" height="360" fill="#299657"/>
|
||||||
|
</svg>`);
|
||||||
|
fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTextEpub(file) {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
|
||||||
|
zip.file('META-INF/container.xml', `<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles><rootfile full-path="book.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||||
|
</container>`);
|
||||||
|
zip.file('META-INF/encryption.xml', `<encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container"
|
||||||
|
xmlns:enc="http://www.w3.org/2001/04/xmlenc#">
|
||||||
|
<enc:EncryptedData>
|
||||||
|
<enc:EncryptionMethod Algorithm="http://www.idpf.org/2008/embedding"/>
|
||||||
|
<enc:CipherData><enc:CipherReference URI="fonts/obfuscated.otf"/></enc:CipherData>
|
||||||
|
</enc:EncryptedData>
|
||||||
|
</encryption>`);
|
||||||
|
zip.file('book.opf', `<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Text Only Fixture</dc:title></metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="font" href="fonts/obfuscated.otf" media-type="application/vnd.ms-opentype"/>
|
||||||
|
</manifest>
|
||||||
|
<spine><itemref idref="chapter"/></spine>
|
||||||
|
</package>`);
|
||||||
|
zip.file('chapter.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><body>Text only</body></html>');
|
||||||
|
zip.file('fonts/obfuscated.otf', Buffer.from('fixture'));
|
||||||
|
fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCover(entryId, library) {
|
||||||
|
const deadline = Date.now() + 35000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const entry = library.get(entryId);
|
||||||
|
if (entry && entry.cover && !/^https?:/i.test(entry.cover) && fs.existsSync(entry.cover)) return entry;
|
||||||
|
await wait(150);
|
||||||
|
}
|
||||||
|
return library.get(entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleCover(file) {
|
||||||
|
const image = nativeImage.createFromPath(file);
|
||||||
|
const size = image.getSize();
|
||||||
|
const pixel = Array.from(image.crop({
|
||||||
|
x: Math.floor(size.width / 2),
|
||||||
|
y: Math.floor(size.height / 2),
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}).toBitmap());
|
||||||
|
return { width: size.width, height: size.height, pixel };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
const pdfPath = path.join(TMP, 'local.pdf');
|
||||||
|
const epubPath = path.join(TMP, 'local.epub');
|
||||||
|
const firstPageEpubPath = path.join(TMP, 'first-page.epub');
|
||||||
|
const textEpubPath = path.join(TMP, 'text-only.epub');
|
||||||
|
makePdf(pdfPath);
|
||||||
|
await makeEpub(epubPath);
|
||||||
|
await makeFirstPageEpub(firstPageEpubPath);
|
||||||
|
await makeTextEpub(textEpubPath);
|
||||||
|
|
||||||
|
require(path.join(ROOT, 'main.js'));
|
||||||
|
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||||||
|
const coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
|
||||||
|
library.init(path.join(TMP, 'library'));
|
||||||
|
await wait(600);
|
||||||
|
|
||||||
|
const win = BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'PeopleLib');
|
||||||
|
if (!win) throw new Error('主窗口未创建');
|
||||||
|
win.hide();
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
'(()=>{window.__coverChangeCount=0;window.api.library.onChanged(()=>window.__coverChangeCount++);return true})()'
|
||||||
|
);
|
||||||
|
|
||||||
|
const addResult = await win.webContents.executeJavaScript(`window.api.library.add({
|
||||||
|
title: 'Local PDF',
|
||||||
|
authors: ['Fixture'],
|
||||||
|
files: [{ path: ${JSON.stringify(pdfPath)}, name: 'local.pdf', format: 'PDF' }]
|
||||||
|
})`);
|
||||||
|
const pdfEntry = await waitForCover(addResult.data.id, library);
|
||||||
|
check('本地 PDF 自动生成封面', !!pdfEntry.cover && fs.existsSync(pdfEntry.cover), pdfEntry.cover);
|
||||||
|
const pdfSample = sampleCover(pdfEntry.cover);
|
||||||
|
check('PDF 封面来自第一页', pdfSample.pixel[0] > pdfSample.pixel[2] * 1.5, pdfSample.pixel.join(','));
|
||||||
|
check('PDF 缩略图尺寸受限', pdfSample.width <= 320 && pdfSample.height <= 440,
|
||||||
|
`${pdfSample.width}x${pdfSample.height}`);
|
||||||
|
check('异步生成完成后通知主界面刷新',
|
||||||
|
(await win.webContents.executeJavaScript('window.__coverChangeCount')) > 0);
|
||||||
|
|
||||||
|
const epubResult = await win.webContents.executeJavaScript(`window.api.library.add({
|
||||||
|
title: 'Local EPUB',
|
||||||
|
authors: [],
|
||||||
|
files: [{ path: ${JSON.stringify(epubPath)}, name: 'local.epub', format: 'EPUB' }]
|
||||||
|
})`);
|
||||||
|
const epubEntry = await waitForCover(epubResult.data.id, library);
|
||||||
|
check('本地 EPUB 自动生成封面', !!epubEntry.cover && fs.existsSync(epubEntry.cover), epubEntry.cover);
|
||||||
|
const epubSample = sampleCover(epubEntry.cover);
|
||||||
|
check('EPUB 优先使用内嵌封面', epubSample.pixel[2] > epubSample.pixel[0] * 1.5, epubSample.pixel.join(','));
|
||||||
|
|
||||||
|
const firstPageResult = await win.webContents.executeJavaScript(`window.api.library.add({
|
||||||
|
title: 'First Page EPUB',
|
||||||
|
authors: [],
|
||||||
|
files: [{ path: ${JSON.stringify(firstPageEpubPath)}, name: 'first-page.epub', format: 'EPUB' }]
|
||||||
|
})`);
|
||||||
|
const firstPageEntry = await waitForCover(firstPageResult.data.id, library);
|
||||||
|
const firstPageSample = sampleCover(firstPageEntry.cover);
|
||||||
|
check('EPUB 无封面元数据时使用首页图片',
|
||||||
|
firstPageSample.pixel[1] > firstPageSample.pixel[0] * 1.5, firstPageSample.pixel.join(','));
|
||||||
|
|
||||||
|
const textResult = await win.webContents.executeJavaScript(`window.api.library.add({
|
||||||
|
title: 'Text Only EPUB',
|
||||||
|
authors: ['Fixture'],
|
||||||
|
files: [{ path: ${JSON.stringify(textEpubPath)}, name: 'text-only.epub', format: 'EPUB' }]
|
||||||
|
})`);
|
||||||
|
const textEntry = await waitForCover(textResult.data.id, library);
|
||||||
|
check('含字体混淆的纯文本 EPUB 生成标题封面', !!textEntry.cover && fs.existsSync(textEntry.cover));
|
||||||
|
|
||||||
|
const sourceCover = 'data:image/png;base64,iVBORw0KGgo=';
|
||||||
|
const sourceResult = await win.webContents.executeJavaScript(`window.api.library.add({
|
||||||
|
title: 'Source Cover Priority',
|
||||||
|
cover: ${JSON.stringify(sourceCover)},
|
||||||
|
files: [{ path: ${JSON.stringify(pdfPath)}, name: 'local.pdf', format: 'PDF' }]
|
||||||
|
})`);
|
||||||
|
await wait(800);
|
||||||
|
check('已有来源封面不被生成封面替换', library.get(sourceResult.data.id).cover === sourceCover);
|
||||||
|
|
||||||
|
const badPdfPath = path.join(TMP, 'broken.pdf');
|
||||||
|
fs.writeFileSync(badPdfPath, 'not a pdf');
|
||||||
|
const badResult = await win.webContents.executeJavaScript(`window.api.library.add({
|
||||||
|
title: 'Broken PDF',
|
||||||
|
files: [{ path: ${JSON.stringify(badPdfPath)}, name: 'broken.pdf', format: 'PDF' }]
|
||||||
|
})`);
|
||||||
|
await coverGenerator.ensure(badResult.data.id).catch(() => {});
|
||||||
|
check('损坏 PDF 不阻断入库且不写入假封面', badResult.ok && !library.get(badResult.data.id).cover);
|
||||||
|
|
||||||
|
const changed = library.add({
|
||||||
|
title: 'Changing file',
|
||||||
|
files: [{ path: pdfPath, name: 'local.pdf', format: 'PDF' }]
|
||||||
|
});
|
||||||
|
const changingJob = coverGenerator.ensure(changed.id);
|
||||||
|
library.update(changed.id, {
|
||||||
|
title: 'Changed to EPUB',
|
||||||
|
files: [{ path: epubPath, name: 'local.epub', format: 'EPUB' }]
|
||||||
|
});
|
||||||
|
await changingJob;
|
||||||
|
const changedEntry = library.get(changed.id);
|
||||||
|
const changedSample = sampleCover(changedEntry.cover);
|
||||||
|
check('提取期间文件变更会丢弃旧结果并重新生成',
|
||||||
|
changedSample.pixel[2] > changedSample.pixel[0] * 1.5, changedSample.pixel.join(','));
|
||||||
|
|
||||||
|
const scanPdf = path.join(library.filesDir(), 'scanned.pdf');
|
||||||
|
fs.copyFileSync(pdfPath, scanPdf);
|
||||||
|
const scanResult = await win.webContents.executeJavaScript('window.api.library.scan()');
|
||||||
|
const scanned = library.list().find((entry) => entry.files.some((file) => file.path === scanPdf));
|
||||||
|
const scannedEntry = scanned && await waitForCover(scanned.id, library);
|
||||||
|
check('目录扫描条目自动生成封面', scanResult.data.added === 1
|
||||||
|
&& !!scannedEntry && fs.existsSync(scannedEntry.cover || ''));
|
||||||
|
|
||||||
|
const server = http.createServer((_request, response) => {
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'attachment; filename="download.pdf"'
|
||||||
|
});
|
||||||
|
response.end(fs.readFileSync(pdfPath));
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const downloadResult = await win.webContents.executeJavaScript(`window.api.downloadFile(
|
||||||
|
${JSON.stringify(`http://127.0.0.1:${server.address().port}/download.pdf`)},
|
||||||
|
'download.pdf',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ title: 'Downloaded PDF', authors: [], cover: '', sourceId: 'fixture', sourcePostId: '1' }
|
||||||
|
)`);
|
||||||
|
const downloadedEntry = downloadResult.ok && await waitForCover(downloadResult.data.entryId, library);
|
||||||
|
check('来源下载并挂载后自动生成封面', !!downloadedEntry && fs.existsSync(downloadedEntry.cover || ''));
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
|
||||||
|
check('所有生成封面均为 JPEG',
|
||||||
|
[pdfEntry, epubEntry, firstPageEntry, textEntry, changedEntry, scannedEntry, downloadedEntry].every((entry) => {
|
||||||
|
if (!entry || !entry.cover) return false;
|
||||||
|
const bytes = fs.readFileSync(entry.cover);
|
||||||
|
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('\n========== 自动封面集成验证 ==========');
|
||||||
|
for (const [status, name, detail] of results) {
|
||||||
|
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
|
||||||
|
}
|
||||||
|
const failed = results.filter((result) => result[0] === 'FAIL').length;
|
||||||
|
console.log(`\n通过 ${results.length - failed}/${results.length}`);
|
||||||
|
coverGenerator.close();
|
||||||
|
win.destroy();
|
||||||
|
app.exit(failed ? 1 : 0);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('异常:', error);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
// Validate the real main-process download stream, preload progress bridge, library
|
||||||
|
// attachment, and the completed-download button styling without external network.
|
||||||
|
const { app, BrowserWindow, safeStorage } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-download-ui-'));
|
||||||
|
const LIBRARY_DIR = path.join(TMP, 'library');
|
||||||
|
app.setPath('userData', TMP);
|
||||||
|
// main.js derives its development userData directory from appData. Redirect both
|
||||||
|
// before requiring it so even its initial module setup cannot touch the real profile.
|
||||||
|
app.setPath('appData', TMP);
|
||||||
|
|
||||||
|
const knownChunks = Array.from({ length: 6 }, (_unused, index) => Buffer.from(
|
||||||
|
`known-chunk-${index}-` + String.fromCharCode(65 + index).repeat(24 * 1024)
|
||||||
|
));
|
||||||
|
const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
|
||||||
|
`unknown-chunk-${index}-` + String.fromCharCode(97 + index).repeat(12 * 1024)
|
||||||
|
));
|
||||||
|
const rangedChunks = Array.from({ length: 12 }, (_unused, index) => Buffer.from(
|
||||||
|
`ranged-chunk-${index}-` + String.fromCharCode(75 + (index % 10)).repeat(16 * 1024)
|
||||||
|
));
|
||||||
|
const knownPayload = Buffer.concat(knownChunks);
|
||||||
|
const unknownPayload = Buffer.concat(unknownChunks);
|
||||||
|
const rangedPayload = Buffer.concat(rangedChunks);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const rangedRequests = [];
|
||||||
|
let server;
|
||||||
|
let testWindow;
|
||||||
|
|
||||||
|
function check(name, condition, detail = '') {
|
||||||
|
results.push([condition ? 'OK' : 'FAIL', name, detail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRenderer(expression, timeout = 8000) {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeout) {
|
||||||
|
if (await testWindow.webContents.executeJavaScript(`Boolean(${expression})`)) return true;
|
||||||
|
await wait(50);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveChunks(response, chunks, contentLength) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="fixture.txt"',
|
||||||
|
Connection: 'close'
|
||||||
|
};
|
||||||
|
if (contentLength != null) headers['Content-Length'] = String(contentLength);
|
||||||
|
response.writeHead(200, headers);
|
||||||
|
if (response.socket) response.socket.setNoDelay(true);
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const sendNext = () => {
|
||||||
|
if (index >= chunks.length) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.write(chunks[index]);
|
||||||
|
index += 1;
|
||||||
|
setTimeout(sendNext, 130);
|
||||||
|
};
|
||||||
|
sendNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveRange(request, response) {
|
||||||
|
const match = String(request.headers.range || '').match(/^bytes=(\d+)-$/);
|
||||||
|
const start = match ? Number(match[1]) : 0;
|
||||||
|
rangedRequests.push({ url: request.url, start });
|
||||||
|
if (!Number.isSafeInteger(start) || start < 0 || start >= rangedPayload.length) {
|
||||||
|
response.writeHead(416, {
|
||||||
|
'Content-Range': `bytes */${rangedPayload.length}`,
|
||||||
|
Connection: 'close'
|
||||||
|
});
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
for (let offset = start; offset < rangedPayload.length; offset += 16 * 1024) {
|
||||||
|
chunks.push(rangedPayload.subarray(offset, Math.min(rangedPayload.length, offset + 16 * 1024)));
|
||||||
|
}
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="ranged-fixture.txt"',
|
||||||
|
'Content-Length': String(rangedPayload.length - start),
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
ETag: '"ranged-fixture-v1"',
|
||||||
|
Connection: 'close'
|
||||||
|
};
|
||||||
|
if (start) headers['Content-Range'] = `bytes ${start}-${rangedPayload.length - 1}/${rangedPayload.length}`;
|
||||||
|
response.writeHead(start ? 206 : 200, headers);
|
||||||
|
if (response.socket) response.socket.setNoDelay(true);
|
||||||
|
let index = 0;
|
||||||
|
const sendNext = () => {
|
||||||
|
if (response.destroyed || response.writableEnded) return;
|
||||||
|
if (index >= chunks.length) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.write(chunks[index++]);
|
||||||
|
setTimeout(sendNext, 130);
|
||||||
|
};
|
||||||
|
sendNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function monotonic(events) {
|
||||||
|
return events.every((event, index) => {
|
||||||
|
const current = Number(event.receivedBytes);
|
||||||
|
const previous = index ? Number(events[index - 1].receivedBytes) : 0;
|
||||||
|
return Number.isFinite(current) && current >= 0 && current >= previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithin(base, target) {
|
||||||
|
const relative = path.relative(path.resolve(base), path.resolve(target));
|
||||||
|
return relative === '' || (!relative.startsWith(`..${path.sep}`)
|
||||||
|
&& relative !== '..' && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssRule(css, selector) {
|
||||||
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaration(rule, property) {
|
||||||
|
const match = rule.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i'));
|
||||||
|
return match ? match[1].trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCssColor(value, css) {
|
||||||
|
let color = String(value || '').trim();
|
||||||
|
const variable = color.match(/^var\((--[\w-]+)\)$/);
|
||||||
|
if (variable) {
|
||||||
|
const match = css.match(new RegExp(`${variable[1]}\\s*:\\s*([^;]+)`, 'i'));
|
||||||
|
color = match ? match[1].trim() : '';
|
||||||
|
}
|
||||||
|
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||||
|
if (!hex) return null;
|
||||||
|
const digits = hex[1].length === 3
|
||||||
|
? hex[1].split('').map((digit) => digit + digit).join('')
|
||||||
|
: hex[1];
|
||||||
|
return [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(rgb) {
|
||||||
|
if (!rgb) return NaN;
|
||||||
|
const channels = rgb.map((value) => {
|
||||||
|
const channel = value / 255;
|
||||||
|
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrast(a, b) {
|
||||||
|
const first = luminance(a);
|
||||||
|
const second = luminance(b);
|
||||||
|
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadInRenderer(url, name, meta, slot) {
|
||||||
|
return testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
window[${JSON.stringify(slot)}] = [];
|
||||||
|
return window.api.downloadFile(
|
||||||
|
${JSON.stringify(url)},
|
||||||
|
${JSON.stringify(name)},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
${JSON.stringify(meta)},
|
||||||
|
(event) => window[${JSON.stringify(slot)}].push({ ...event })
|
||||||
|
).then((result) => ({ result, events: window[${JSON.stringify(slot)}] }));
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeServer() {
|
||||||
|
if (!server || !server.listening) return;
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
server = http.createServer((request, response) => {
|
||||||
|
if (request.url === '/known.txt') {
|
||||||
|
serveChunks(response, knownChunks, knownPayload.length);
|
||||||
|
} else if (request.url === '/unknown.txt') {
|
||||||
|
serveChunks(response, unknownChunks, null);
|
||||||
|
} else if (request.url.startsWith('/range.txt')) {
|
||||||
|
serveRange(request, response);
|
||||||
|
} else {
|
||||||
|
response.writeHead(404, { Connection: 'close' });
|
||||||
|
response.end('not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
require(path.join(ROOT, 'main.js'));
|
||||||
|
|
||||||
|
// main.js initializes these modules as a side effect. Reinitialize every
|
||||||
|
// profile-backed store against this harness's isolated temporary directory.
|
||||||
|
const settings = require(path.join(ROOT, 'src', 'settings'));
|
||||||
|
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
||||||
|
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
|
||||||
|
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
||||||
|
const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
|
||||||
|
const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
|
||||||
|
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||||||
|
settings.init(TMP);
|
||||||
|
readerStore.init(TMP);
|
||||||
|
annotations.init(TMP);
|
||||||
|
aiConfig.init(TMP, safeStorage);
|
||||||
|
zlibAuth.init(TMP, safeStorage);
|
||||||
|
semanticKey.init(TMP, safeStorage);
|
||||||
|
library.init(LIBRARY_DIR);
|
||||||
|
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
|
||||||
|
|
||||||
|
const htmlPath = path.join(TMP, 'download-test.html');
|
||||||
|
fs.writeFileSync(htmlPath, `<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
|
||||||
|
<title>Download integration</title>
|
||||||
|
</head><body><main id="ready">ready</main></body></html>`);
|
||||||
|
|
||||||
|
const rendererErrors = [];
|
||||||
|
testWindow = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(ROOT, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
testWindow.webContents.on('console-message', (event) => {
|
||||||
|
const { level, message } = event;
|
||||||
|
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
|
||||||
|
rendererErrors.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
testWindow.webContents.on('preload-error', (_event, _preloadPath, error) => {
|
||||||
|
rendererErrors.push(`preload: ${error.message}`);
|
||||||
|
});
|
||||||
|
testWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||||
|
rendererErrors.push(`renderer gone: ${details.reason}`);
|
||||||
|
});
|
||||||
|
testWindow.webContents.on('did-fail-load', (_event, code, description, validatedURL, isMainFrame) => {
|
||||||
|
if (isMainFrame) rendererErrors.push(`load ${code}: ${description} (${validatedURL})`);
|
||||||
|
});
|
||||||
|
await testWindow.loadFile(htmlPath);
|
||||||
|
await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
window.__pageErrors = [];
|
||||||
|
addEventListener('error', (event) => window.__pageErrors.push(String(event.message || event.error)));
|
||||||
|
addEventListener('unhandledrejection', (event) => window.__pageErrors.push(String(event.reason)));
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
check('preload 暴露下载 API',
|
||||||
|
await testWindow.webContents.executeJavaScript(
|
||||||
|
'typeof window.api.downloadFile === "function"'
|
||||||
|
+ ' && typeof window.api.downloads.run === "function"'
|
||||||
|
+ ' && typeof window.api.downloads.pause === "function"'
|
||||||
|
+ ' && typeof window.api.downloads.delete === "function"'
|
||||||
|
));
|
||||||
|
|
||||||
|
const port = server.address().port;
|
||||||
|
const known = await downloadInRenderer(
|
||||||
|
`http://127.0.0.1:${port}/known.txt`,
|
||||||
|
'known-fixture.txt',
|
||||||
|
{
|
||||||
|
title: 'Known Length Download',
|
||||||
|
authors: ['Integration Fixture'],
|
||||||
|
sourceId: 'download-test',
|
||||||
|
sourcePostId: 'known'
|
||||||
|
},
|
||||||
|
'__knownProgress'
|
||||||
|
);
|
||||||
|
const knownEvents = known.events || [];
|
||||||
|
const knownResult = known.result;
|
||||||
|
const knownFinal = knownEvents[knownEvents.length - 1] || {};
|
||||||
|
check('Content-Length 下载成功', !!(knownResult && knownResult.ok),
|
||||||
|
knownResult && knownResult.error);
|
||||||
|
check('Content-Length 下载产生多个进度事件',
|
||||||
|
knownEvents.length >= 4 && new Set(knownEvents.map((event) => event.receivedBytes)).size >= 3,
|
||||||
|
`事件=${knownEvents.length}`);
|
||||||
|
check('Content-Length 进度单调递增', monotonic(knownEvents),
|
||||||
|
knownEvents.map((event) => event.receivedBytes).join(','));
|
||||||
|
check('Content-Length 进度总量正确',
|
||||||
|
knownEvents.length > 0 && knownEvents.every((event) => event.totalBytes === knownPayload.length),
|
||||||
|
`期望=${knownPayload.length}`);
|
||||||
|
check('Content-Length 最终进度完整',
|
||||||
|
knownFinal.complete === true && knownFinal.percent === 1
|
||||||
|
&& knownFinal.receivedBytes === knownPayload.length,
|
||||||
|
JSON.stringify(knownFinal));
|
||||||
|
|
||||||
|
const knownPath = knownResult && knownResult.ok && knownResult.data.path;
|
||||||
|
check('Content-Length 下载字节完全一致',
|
||||||
|
!!knownPath && fs.existsSync(knownPath) && fs.readFileSync(knownPath).equals(knownPayload),
|
||||||
|
knownPath || '');
|
||||||
|
const knownEntry = knownResult && knownResult.ok
|
||||||
|
? library.get(knownResult.data.entryId) : null;
|
||||||
|
check('Content-Length 下载挂载到书库条目',
|
||||||
|
!!knownEntry && knownEntry.title === 'Known Length Download'
|
||||||
|
&& knownEntry.files.some((file) => file.path === knownPath && file.exists),
|
||||||
|
knownEntry && knownEntry.id);
|
||||||
|
check('下载文件仅写入隔离书库', !!knownPath && isWithin(LIBRARY_DIR, knownPath), knownPath || '');
|
||||||
|
|
||||||
|
const unknown = await downloadInRenderer(
|
||||||
|
`http://127.0.0.1:${port}/unknown.txt`,
|
||||||
|
'unknown-fixture.txt',
|
||||||
|
{
|
||||||
|
title: 'Unknown Length Download',
|
||||||
|
authors: [],
|
||||||
|
sourceId: 'download-test',
|
||||||
|
sourcePostId: 'unknown'
|
||||||
|
},
|
||||||
|
'__unknownProgress'
|
||||||
|
);
|
||||||
|
const unknownEvents = unknown.events || [];
|
||||||
|
const unknownResult = unknown.result;
|
||||||
|
const unknownFinal = unknownEvents[unknownEvents.length - 1] || {};
|
||||||
|
check('无 Content-Length 下载成功', !!(unknownResult && unknownResult.ok),
|
||||||
|
unknownResult && unknownResult.error);
|
||||||
|
check('无 Content-Length 下载产生多个单调进度事件',
|
||||||
|
unknownEvents.length >= 4 && monotonic(unknownEvents), `事件=${unknownEvents.length}`);
|
||||||
|
check('无 Content-Length 使用不确定进度',
|
||||||
|
unknownEvents.some((event) => !event.complete && event.receivedBytes > 0
|
||||||
|
&& event.totalBytes === null && event.percent === null),
|
||||||
|
JSON.stringify(unknownEvents.slice(0, 3)));
|
||||||
|
check('无 Content-Length 最终进度完整',
|
||||||
|
unknownFinal.complete === true && unknownFinal.percent === 1
|
||||||
|
&& unknownFinal.totalBytes === null
|
||||||
|
&& unknownFinal.receivedBytes === unknownPayload.length,
|
||||||
|
JSON.stringify(unknownFinal));
|
||||||
|
|
||||||
|
const unknownPath = unknownResult && unknownResult.ok && unknownResult.data.path;
|
||||||
|
check('无 Content-Length 下载字节完全一致',
|
||||||
|
!!unknownPath && fs.existsSync(unknownPath)
|
||||||
|
&& fs.readFileSync(unknownPath).equals(unknownPayload),
|
||||||
|
unknownPath || '');
|
||||||
|
const unknownEntry = unknownResult && unknownResult.ok
|
||||||
|
? library.get(unknownResult.data.entryId) : null;
|
||||||
|
check('无 Content-Length 下载挂载到书库条目',
|
||||||
|
!!unknownEntry && unknownEntry.title === 'Unknown Length Download'
|
||||||
|
&& unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
|
||||||
|
unknownEntry && unknownEntry.id);
|
||||||
|
|
||||||
|
const directPageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
|
||||||
|
await testWindow.loadFile(path.join(ROOT, 'src', 'ui', 'index.html'));
|
||||||
|
await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
window.__pageErrors = [];
|
||||||
|
addEventListener('error', (event) => window.__pageErrors.push(String(event.message || event.error)));
|
||||||
|
addEventListener('unhandledrejection', (event) => window.__pageErrors.push(String(event.reason)));
|
||||||
|
})()`);
|
||||||
|
const centerReady = await waitForRenderer(
|
||||||
|
'window.DownloadCenter && document.getElementById("taskCenterBtn").onclick'
|
||||||
|
);
|
||||||
|
check('主界面加载任务中心', centerReady);
|
||||||
|
|
||||||
|
const centerStarted = await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
window.__centerResult = null;
|
||||||
|
window.DownloadCenter.start({
|
||||||
|
key: 'integration-center-download',
|
||||||
|
url: ${JSON.stringify(`http://127.0.0.1:${port}/known.txt`)},
|
||||||
|
suggestName: 'center-fixture.txt',
|
||||||
|
meta: {
|
||||||
|
title: 'Task Center Download',
|
||||||
|
authors: ['Integration Fixture'],
|
||||||
|
sourceId: 'download-test',
|
||||||
|
sourcePostId: 'center'
|
||||||
|
}
|
||||||
|
}).then((result) => { window.__centerResult = result; });
|
||||||
|
document.getElementById('taskCenterBtn').click();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
check('任务中心可发起下载', centerStarted);
|
||||||
|
const centerRunning = await waitForRenderer(
|
||||||
|
'document.querySelector(".task-center-item.running")'
|
||||||
|
);
|
||||||
|
check('任务中心显示进行中任务', centerRunning);
|
||||||
|
check('进行中任务显示实时字节进度',
|
||||||
|
await waitForRenderer(
|
||||||
|
'document.querySelector(".task-center-item.running .task-center-status")'
|
||||||
|
+ ' && /已下载|%/.test(document.querySelector(".task-center-item.running .task-center-status").textContent)'
|
||||||
|
));
|
||||||
|
|
||||||
|
await testWindow.webContents.executeJavaScript(
|
||||||
|
'document.querySelector(".tab[data-tab=\\"settings\\"]").click()'
|
||||||
|
);
|
||||||
|
check('切换页面后任务中心仍保留下载',
|
||||||
|
await testWindow.webContents.executeJavaScript(
|
||||||
|
'!document.getElementById("settingsTab").classList.contains("hidden")'
|
||||||
|
+ ' && !!document.querySelector(".task-center-item.running")'
|
||||||
|
));
|
||||||
|
|
||||||
|
const centerComplete = await waitForRenderer(
|
||||||
|
'window.__centerResult && document.querySelector(".task-center-item.complete")',
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
check('切换页面后下载继续并完成', centerComplete);
|
||||||
|
const centerState = await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
const item = document.querySelector('.task-center-item.complete');
|
||||||
|
return {
|
||||||
|
result: window.__centerResult,
|
||||||
|
state: item && item.querySelector('.task-center-state').textContent,
|
||||||
|
hasOpen: !!(item && item.querySelector('[data-task-action="open"]')),
|
||||||
|
hasReveal: !!(item && item.querySelector('[data-task-action="reveal"]'))
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check('已完成任务提供打开与定位入口',
|
||||||
|
centerState.state === '已完成' && centerState.hasOpen && centerState.hasReveal,
|
||||||
|
JSON.stringify(centerState));
|
||||||
|
const centerPath = centerState.result && centerState.result.ok && centerState.result.data.path;
|
||||||
|
check('任务中心下载字节完全一致',
|
||||||
|
!!centerPath && fs.existsSync(centerPath) && fs.readFileSync(centerPath).equals(knownPayload),
|
||||||
|
centerPath || '');
|
||||||
|
const centerEntry = centerState.result && centerState.result.ok
|
||||||
|
? library.get(centerState.result.data.entryId) : null;
|
||||||
|
check('任务中心下载自动挂载到书库',
|
||||||
|
!!centerEntry && centerEntry.title === 'Task Center Download'
|
||||||
|
&& centerEntry.files.some((file) => file.path === centerPath && file.exists),
|
||||||
|
centerEntry && centerEntry.id);
|
||||||
|
|
||||||
|
await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
window.__resumeFirst = null;
|
||||||
|
window.DownloadCenter.start({
|
||||||
|
key: 'integration-resume-download',
|
||||||
|
url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?resume=1`)},
|
||||||
|
suggestName: 'resume-fixture.txt',
|
||||||
|
meta: {
|
||||||
|
title: 'Resume Download',
|
||||||
|
authors: [],
|
||||||
|
sourceId: 'download-test',
|
||||||
|
sourcePostId: 'resume'
|
||||||
|
}
|
||||||
|
}).then((result) => { window.__resumeFirst = result; });
|
||||||
|
})()`);
|
||||||
|
const resumeProgress = await waitForRenderer(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||||
|
return item && item.classList.contains('running')
|
||||||
|
&& parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
|
||||||
|
})()`);
|
||||||
|
check('可续传任务开始下载并产生进度', resumeProgress);
|
||||||
|
await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||||
|
item.querySelector('[data-task-action="pause"]').click();
|
||||||
|
})()`);
|
||||||
|
const paused = await waitForRenderer(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||||
|
return item && item.classList.contains('paused')
|
||||||
|
&& item.querySelector('.task-center-state').textContent === '已暂停'
|
||||||
|
&& item.querySelector('[data-task-action="resume"]');
|
||||||
|
})()`);
|
||||||
|
check('任务中心可暂停未完成下载', paused);
|
||||||
|
|
||||||
|
const filesDir = path.join(LIBRARY_DIR, 'files');
|
||||||
|
const pausedParts = fs.readdirSync(filesDir).filter((name) => name.endsWith('.part'));
|
||||||
|
const pausedPart = pausedParts.length === 1 ? path.join(filesDir, pausedParts[0]) : '';
|
||||||
|
const pausedSize = pausedPart && fs.existsSync(pausedPart) ? fs.statSync(pausedPart).size : 0;
|
||||||
|
check('暂停保留未完成文件作为续传断点',
|
||||||
|
pausedParts.length === 1 && pausedSize > 0 && pausedSize < rangedPayload.length,
|
||||||
|
`文件=${pausedParts.join(',')} 大小=${pausedSize}`);
|
||||||
|
|
||||||
|
await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||||
|
item.querySelector('[data-task-action="resume"]').click();
|
||||||
|
})()`);
|
||||||
|
const resumed = await waitForRenderer(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||||
|
return item && item.classList.contains('complete');
|
||||||
|
})()`, 10000);
|
||||||
|
check('任务中心可继续已暂停下载并完成', resumed);
|
||||||
|
const resumeRequests = rangedRequests.filter((request) => request.url.includes('resume=1'));
|
||||||
|
check('继续下载从临时文件末尾发送 Range',
|
||||||
|
resumeRequests.length >= 2 && resumeRequests[1].start === pausedSize && pausedSize > 0,
|
||||||
|
JSON.stringify(resumeRequests));
|
||||||
|
const resumeEntry = library.findBySource('download-test', 'resume');
|
||||||
|
const resumePath = resumeEntry && resumeEntry.files[0] && resumeEntry.files[0].path;
|
||||||
|
check('断点续传后的文件字节完全一致',
|
||||||
|
!!resumePath && fs.existsSync(resumePath)
|
||||||
|
&& fs.readFileSync(resumePath).equals(rangedPayload),
|
||||||
|
resumePath || '');
|
||||||
|
check('断点续传完成后清理临时文件',
|
||||||
|
fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
|
||||||
|
|
||||||
|
await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
window.__deleteResult = null;
|
||||||
|
window.DownloadCenter.start({
|
||||||
|
key: 'integration-delete-download',
|
||||||
|
url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?delete=1`)},
|
||||||
|
suggestName: 'delete-fixture.txt',
|
||||||
|
meta: {
|
||||||
|
title: 'Delete Download',
|
||||||
|
authors: [],
|
||||||
|
sourceId: 'download-test',
|
||||||
|
sourcePostId: 'delete'
|
||||||
|
}
|
||||||
|
}).then((result) => { window.__deleteResult = result; });
|
||||||
|
})()`);
|
||||||
|
check('待删除任务先产生部分内容',
|
||||||
|
await waitForRenderer(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
|
||||||
|
return item && item.classList.contains('running')
|
||||||
|
&& parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
|
||||||
|
})()`));
|
||||||
|
await testWindow.webContents.executeJavaScript(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
|
||||||
|
item.querySelector('[data-task-action="delete"]').click();
|
||||||
|
})()`);
|
||||||
|
const deleted = await waitForRenderer(`(() => {
|
||||||
|
const item = [...document.querySelectorAll('.task-center-item')]
|
||||||
|
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
|
||||||
|
return !item && window.__deleteResult && window.__deleteResult.ok
|
||||||
|
&& window.__deleteResult.data.deleted === true;
|
||||||
|
})()`);
|
||||||
|
check('任务中心可删除进行中的下载', deleted);
|
||||||
|
await wait(150);
|
||||||
|
check('删除未完成任务会清理临时文件',
|
||||||
|
fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
|
||||||
|
check('删除未完成任务不会创建书库条目',
|
||||||
|
!library.findBySource('download-test', 'delete'));
|
||||||
|
|
||||||
|
const css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'style.css'), 'utf8');
|
||||||
|
const downloadedRule = cssRule(css, '.dl-btn.downloaded');
|
||||||
|
const backgroundValue = declaration(downloadedRule, 'background');
|
||||||
|
const foregroundValue = declaration(downloadedRule, 'color');
|
||||||
|
const background = parseCssColor(backgroundValue, css);
|
||||||
|
const foreground = parseCssColor(foregroundValue, css);
|
||||||
|
check('下载完成按钮存在静态样式规则', !!downloadedRule, downloadedRule);
|
||||||
|
check('下载完成按钮使用非蓝绿色背景',
|
||||||
|
!!background && background[1] > background[0] + 20
|
||||||
|
&& background[1] > background[2] + 20
|
||||||
|
&& !/accent|blue/i.test(backgroundValue),
|
||||||
|
`${backgroundValue} -> ${background || '无法解析'}`);
|
||||||
|
check('下载完成按钮使用高对比暗色前景',
|
||||||
|
!!foreground && Math.max(...foreground) < 64 && contrast(background, foreground) >= 4.5,
|
||||||
|
`${foregroundValue}; 对比度=${contrast(background, foreground).toFixed(2)}`);
|
||||||
|
|
||||||
|
await wait(100);
|
||||||
|
const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
|
||||||
|
check('下载流程没有渲染器错误',
|
||||||
|
rendererErrors.length === 0 && directPageErrors.length === 0 && pageErrors.length === 0,
|
||||||
|
rendererErrors.concat(directPageErrors, pageErrors).join(' | '));
|
||||||
|
} catch (error) {
|
||||||
|
check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
const coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
|
||||||
|
coverGenerator.close();
|
||||||
|
} catch (error) { /* main.js may not have loaded */ }
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.destroy();
|
||||||
|
}
|
||||||
|
await closeServer().catch((error) => {
|
||||||
|
check('本地 HTTP 服务器正常关闭', false, error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n========== 下载进度集成验证 ==========');
|
||||||
|
for (const [status, name, detail] of results) {
|
||||||
|
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
|
||||||
|
}
|
||||||
|
const failed = results.filter((result) => result[0] === 'FAIL').length;
|
||||||
|
console.log(`\n通过 ${results.length - failed}/${results.length}`);
|
||||||
|
app.exit(failed ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(run).catch((error) => {
|
||||||
|
console.error('异常:', error);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-startup-ui-'));
|
||||||
|
app.setPath('appData', TMP);
|
||||||
|
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function check(name, pass, detail) {
|
||||||
|
results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntil(fn, timeout = 10000) {
|
||||||
|
const end = Date.now() + timeout;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
try {
|
||||||
|
const value = await fn();
|
||||||
|
if (value) return value;
|
||||||
|
} catch (e) { /* 窗口仍在加载 */ }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
throw new Error(`等待条件超时(${timeout}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary() {
|
||||||
|
console.log('\n========== 启动响应集成验证 ==========');
|
||||||
|
for (const [status, name, detail] of results) {
|
||||||
|
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
|
||||||
|
}
|
||||||
|
const failed = results.filter((item) => item[0] === 'FAIL').length;
|
||||||
|
console.log(`\n通过 ${results.length - failed}/${results.length}`);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||||||
|
let scanStartedAt = 0;
|
||||||
|
library.scan = () => {
|
||||||
|
scanStartedAt = Date.now();
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2200);
|
||||||
|
return { added: 0, missing: 0, total: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
require(path.join(ROOT, 'main.js'));
|
||||||
|
const win = await waitUntil(() => (
|
||||||
|
BrowserWindow.getAllWindows().find((item) => item.getTitle() === 'PeopleLib')
|
||||||
|
));
|
||||||
|
win.hide();
|
||||||
|
await waitUntil(() => win.webContents.executeJavaScript(
|
||||||
|
`document.readyState === 'complete'
|
||||||
|
&& ['dark', 'light'].includes(document.documentElement.dataset.uiTheme)`
|
||||||
|
));
|
||||||
|
const configReadyMs = Date.now() - startedAt;
|
||||||
|
check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
|
||||||
|
|
||||||
|
const sourceRows = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
|
||||||
|
const rows = Array.from(document.querySelectorAll('#sourceList input[data-id]')).map((input) => ({
|
||||||
|
id: input.dataset.id,
|
||||||
|
checked: input.checked,
|
||||||
|
name: input.parentElement.querySelector('span').textContent
|
||||||
|
}));
|
||||||
|
return rows.length >= 16 ? rows : null;
|
||||||
|
})()`));
|
||||||
|
const expectedSources = ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en'];
|
||||||
|
check('新增开放书源出现在设置页且新安装默认启用',
|
||||||
|
expectedSources.every((id) => sourceRows.some((row) => row.id === id && row.checked)),
|
||||||
|
sourceRows.filter((row) => expectedSources.includes(row.id)).map((row) => `${row.id}:${row.name}`).join(', '));
|
||||||
|
|
||||||
|
await waitUntil(() => scanStartedAt > 0, 7000);
|
||||||
|
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
|
||||||
|
`${scanStartedAt - startedAt}ms`);
|
||||||
|
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.destroy();
|
||||||
|
}
|
||||||
|
const failed = printSummary();
|
||||||
|
app.exit(failed ? 1 : 0);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('异常:', error);
|
||||||
|
check('启动验证未发生异常', false, error.message || String(error));
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.destroy();
|
||||||
|
}
|
||||||
|
printSummary();
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 测试用网络桩:在 undici 边界拦截,使 http.js 的超时/重试/cookie 逻辑全部走真实代码。
|
||||||
|
// 必须在 require('../sources/http') 之前调用 installFetchStub。
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const Module = require('module');
|
||||||
|
|
||||||
|
const undiciPath = require.resolve('undici');
|
||||||
|
const httpPath = require.resolve(path.join(__dirname, '..', 'sources', 'http.js'));
|
||||||
|
|
||||||
|
let handler = null;
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
function makeResponse({ status = 200, body = '', headers = {}, url = '' } = {}) {
|
||||||
|
const lower = {};
|
||||||
|
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
|
||||||
|
const text = typeof body === 'string' ? body : JSON.stringify(body);
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
get: (k) => (k.toLowerCase() in lower ? lower[k.toLowerCase()] : null),
|
||||||
|
getSetCookie: () => lower['set-cookie'] || []
|
||||||
|
},
|
||||||
|
text: async () => text,
|
||||||
|
json: async () => JSON.parse(text),
|
||||||
|
body: { cancel: async () => {} }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 让桩 fetch 尊重 AbortSignal,这样超时与竞速中止是真的在被验证
|
||||||
|
function abortable(signal, work) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal && signal.aborted) {
|
||||||
|
const e = new Error('aborted');
|
||||||
|
e.name = 'AbortError';
|
||||||
|
return reject(e);
|
||||||
|
}
|
||||||
|
let done = false;
|
||||||
|
const onAbort = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
const e = new Error('aborted');
|
||||||
|
e.name = 'AbortError';
|
||||||
|
reject(e);
|
||||||
|
};
|
||||||
|
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
Promise.resolve()
|
||||||
|
.then(work)
|
||||||
|
.then((v) => { if (!done) { done = true; resolve(v); } })
|
||||||
|
.catch((e) => { if (!done) { done = true; reject(e); } })
|
||||||
|
.finally(() => { if (signal) signal.removeEventListener('abort', onAbort); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFetchStub() {
|
||||||
|
const stub = {
|
||||||
|
exports: {
|
||||||
|
ProxyAgent: class { async close() {} },
|
||||||
|
fetch: (url, options = {}) => {
|
||||||
|
const u = String(url);
|
||||||
|
calls.push({ url: u, options });
|
||||||
|
if (!handler) throw new Error('未设置 fetch handler: ' + u);
|
||||||
|
return abortable(options.signal, () => handler(u, options));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loaded: true,
|
||||||
|
id: undiciPath,
|
||||||
|
filename: undiciPath,
|
||||||
|
paths: []
|
||||||
|
};
|
||||||
|
require.cache[undiciPath] = stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHandler(fn) { handler = fn; }
|
||||||
|
function getCalls() { return calls; }
|
||||||
|
function resetCalls() { calls.length = 0; }
|
||||||
|
|
||||||
|
// 按 URL 子串匹配的路由表,未命中则抛错(避免测试静默通过)
|
||||||
|
function routes(table) {
|
||||||
|
return (url) => {
|
||||||
|
for (const [pattern, value] of table) {
|
||||||
|
const hit = pattern instanceof RegExp ? pattern.test(url) : url.includes(pattern);
|
||||||
|
if (hit) return typeof value === 'function' ? value(url) : makeResponse(value);
|
||||||
|
}
|
||||||
|
throw new Error('未匹配的请求: ' + url);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清掉数据源与 http 的模块缓存,让每个用例拿到干净的镜像状态 / cookie jar
|
||||||
|
function freshRequire(relPath) {
|
||||||
|
const target = require.resolve(path.join(__dirname, '..', relPath));
|
||||||
|
delete require.cache[target];
|
||||||
|
delete require.cache[httpPath];
|
||||||
|
const mirrorPath = require.resolve(path.join(__dirname, '..', 'sources', 'mirror.js'));
|
||||||
|
delete require.cache[mirrorPath];
|
||||||
|
return require(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从源文件里取出单个函数做隔离测试(用于未导出的内部函数与 main.js)
|
||||||
|
function extractFns(absFile, from, to, names, preamble = '') {
|
||||||
|
const src = require('fs').readFileSync(absFile, 'utf8');
|
||||||
|
const start = src.indexOf(from);
|
||||||
|
if (start < 0) throw new Error(`未找到起点: ${from}`);
|
||||||
|
const end = to ? src.indexOf(to, start) : src.length;
|
||||||
|
if (to && end < 0) throw new Error(`未找到终点: ${to}`);
|
||||||
|
const seg = src.slice(start, end);
|
||||||
|
const mod = { exports: {} };
|
||||||
|
new Function('module', 'require', `${preamble}\n${seg}\nmodule.exports = { ${names.join(', ')} };`)(mod, require);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
installFetchStub, setHandler, getCalls, resetCalls,
|
||||||
|
makeResponse, routes, freshRequire, extractFns, httpPath
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const h = require('./helpers');
|
||||||
|
|
||||||
|
h.installFetchStub();
|
||||||
|
const http = require('../sources/http');
|
||||||
|
|
||||||
|
test('外部 signal 不会顶替超时保护', async () => {
|
||||||
|
const outer = new AbortController();
|
||||||
|
h.setHandler(() => new Promise(() => {}));
|
||||||
|
const t0 = Date.now();
|
||||||
|
await assert.rejects(
|
||||||
|
http.fetchText('https://x/slow', { timeout: 120, retries: 0, signal: outer.signal }),
|
||||||
|
/请求超时/
|
||||||
|
);
|
||||||
|
assert.ok(Date.now() - t0 < 2000, '超时没有生效');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('外部 signal 触发时报"已取消"而不是"超时"', async () => {
|
||||||
|
const outer = new AbortController();
|
||||||
|
h.setHandler(() => new Promise(() => {}));
|
||||||
|
setTimeout(() => outer.abort(), 30);
|
||||||
|
await assert.rejects(
|
||||||
|
http.fetchText('https://x/cancel', { timeout: 10000, retries: 0, signal: outer.signal }),
|
||||||
|
/请求已取消/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取消不可重试,超时可重试', () => {
|
||||||
|
assert.strictEqual(http.isRetryable(new Error('请求已取消')), false);
|
||||||
|
assert.strictEqual(http.isRetryable(new Error('请求超时,站点无响应')), true);
|
||||||
|
assert.strictEqual(http.isRetryable(new Error('站点网关错误(502)')), true);
|
||||||
|
assert.strictEqual(http.isRetryable(new Error('资源不存在(404)')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取消后不会浪费一次重试', async () => {
|
||||||
|
const outer = new AbortController();
|
||||||
|
let n = 0;
|
||||||
|
h.setHandler(() => { n++; return new Promise(() => {}); });
|
||||||
|
setTimeout(() => outer.abort(), 30);
|
||||||
|
await assert.rejects(
|
||||||
|
http.fetchText('https://x/c2', { timeout: 10000, retries: 1, signal: outer.signal }),
|
||||||
|
/请求已取消/
|
||||||
|
);
|
||||||
|
assert.strictEqual(n, 1, `取消后仍重试了,共 ${n} 次`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('瞬时故障会按 retries 重试', async () => {
|
||||||
|
let n = 0;
|
||||||
|
h.setHandler(() => {
|
||||||
|
n++;
|
||||||
|
if (n === 1) return h.makeResponse({ status: 502 });
|
||||||
|
return h.makeResponse({ body: 'ok' });
|
||||||
|
});
|
||||||
|
const out = await http.fetchText('https://x/retry', { retries: 1, retryDelay: 1 });
|
||||||
|
assert.strictEqual(out, 'ok');
|
||||||
|
assert.strictEqual(n, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4xx 不重试', async () => {
|
||||||
|
let n = 0;
|
||||||
|
h.setHandler(() => { n++; return h.makeResponse({ status: 404 }); });
|
||||||
|
await assert.rejects(http.fetchText('https://x/404', { retries: 1, retryDelay: 1 }), /404/);
|
||||||
|
assert.strictEqual(n, 1, '4xx 不应重试');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchJson 对非 JSON 给出可读错误', async () => {
|
||||||
|
h.setHandler(() => h.makeResponse({ body: '<html>nope</html>' }));
|
||||||
|
await assert.rejects(http.fetchJson('https://x/j', { retries: 0 }), /不是有效 JSON/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setProxy 拒绝非 http(s) 协议', () => {
|
||||||
|
assert.throws(() => http.setProxy('socks5://127.0.0.1:1080'), /仅支持/);
|
||||||
|
http.setProxy('');
|
||||||
|
assert.strictEqual(http.getProxy(), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tooShort / clampPage 边界', () => {
|
||||||
|
assert.strictEqual(http.clampPage(0), 1);
|
||||||
|
assert.strictEqual(http.clampPage('abc'), 1);
|
||||||
|
assert.strictEqual(http.clampPage(-5), 1);
|
||||||
|
assert.strictEqual(http.clampPage('3'), 3);
|
||||||
|
assert.ok(http.tooShort('ab'));
|
||||||
|
assert.strictEqual(http.tooShort('abc'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodeEntities 先解数字实体再解 &,不产生二次解码', () => {
|
||||||
|
assert.strictEqual(http.decodeEntities('a &lt; b'), 'a < b');
|
||||||
|
assert.strictEqual(http.decodeEntities('<b>'), '<b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie 按域存取', () => {
|
||||||
|
http.clearCookies();
|
||||||
|
http.setCookies('https://a.example.com/x', ['k=1; Path=/', 'j=2']);
|
||||||
|
http.setCookies('https://b.example.com/y', ['z=9']);
|
||||||
|
assert.match(http.getCookies('https://a.example.com/other'), /k=1/);
|
||||||
|
assert.match(http.getCookies('https://a.example.com/other'), /j=2/);
|
||||||
|
assert.strictEqual(http.getCookies('https://c.example.com/'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearCookies 接受完整 URL(回退前传 URL 永远清不掉)', () => {
|
||||||
|
http.clearCookies();
|
||||||
|
http.setCookies('https://z-lib.fm/a', ['s=1']);
|
||||||
|
http.clearCookies('https://z-lib.fm');
|
||||||
|
assert.strictEqual(http.getCookies('https://z-lib.fm/a'), '', 'URL 形式的参数未生效');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearCookies 也接受裸主机名,且不误伤其它域', () => {
|
||||||
|
http.clearCookies();
|
||||||
|
http.setCookies('https://z-lib.fm/a', ['s=1']);
|
||||||
|
http.setCookies('https://other.com/a', ['t=2']);
|
||||||
|
http.clearCookies('z-lib.fm');
|
||||||
|
assert.strictEqual(http.getCookies('https://z-lib.fm/a'), '');
|
||||||
|
assert.match(http.getCookies('https://other.com/a'), /t=2/, '误删了其它域的 cookie');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('请求自动带上已存的 cookie', async () => {
|
||||||
|
http.clearCookies();
|
||||||
|
http.setCookies('https://ck.example.com/', ['sid=abc']);
|
||||||
|
let seen = null;
|
||||||
|
h.setHandler((url, opts) => {
|
||||||
|
seen = opts.headers.Cookie;
|
||||||
|
return h.makeResponse({ body: 'ok' });
|
||||||
|
});
|
||||||
|
await http.fetchText('https://ck.example.com/p', { retries: 0 });
|
||||||
|
assert.strictEqual(seen, 'sid=abc');
|
||||||
|
http.clearCookies();
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const store = require('../library/store');
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
|
||||||
|
function freshRoot(tag) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-library-${tag}-`));
|
||||||
|
created.push(root);
|
||||||
|
store.init(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexPath(root) {
|
||||||
|
return path.join(root, 'library.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
store.setChangeListener(null);
|
||||||
|
for (const root of created) {
|
||||||
|
try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('managed tags support CRUD, validation, persistence, and zero-use entries', () => {
|
||||||
|
const root = freshRoot('crud');
|
||||||
|
const tag = store.addTag({ name: ' 技术 ' });
|
||||||
|
assert.match(tag.id, /^tag_[a-f0-9]{24}$/);
|
||||||
|
assert.strictEqual(tag.name, '技术');
|
||||||
|
assert.ok(Number.isFinite(tag.createdAt));
|
||||||
|
assert.ok(Number.isFinite(tag.updatedAt));
|
||||||
|
assert.deepStrictEqual(store.listTags(), [{ ...tag, count: 0 }]);
|
||||||
|
|
||||||
|
assert.throws(() => store.addTag(' '), /不能为空/);
|
||||||
|
assert.throws(() => store.addTag('技术'), /已存在/);
|
||||||
|
assert.throws(() => store.addTag(' 技术 '), /已存在/);
|
||||||
|
assert.throws(() => store.addTag('x'.repeat(65)), /64/);
|
||||||
|
assert.throws(() => store.updateTag(tag.id, { name: '' }), /不能为空/);
|
||||||
|
assert.throws(() => store.updateTag('missing', { name: '新标签' }), /不存在/);
|
||||||
|
|
||||||
|
const renamed = store.updateTag(tag.id, { name: ' 文学 ' });
|
||||||
|
assert.strictEqual(renamed.id, tag.id);
|
||||||
|
assert.strictEqual(renamed.name, '文学');
|
||||||
|
assert.strictEqual(renamed.createdAt, tag.createdAt);
|
||||||
|
assert.ok(renamed.updatedAt >= tag.updatedAt);
|
||||||
|
|
||||||
|
store.init(root);
|
||||||
|
assert.deepStrictEqual(store.listTags(), [{ ...renamed, count: 0 }]);
|
||||||
|
const persisted = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
|
||||||
|
assert.strictEqual(persisted.version, 4);
|
||||||
|
assert.deepStrictEqual(persisted.tags, [renamed]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(store.removeTag(tag.id), { removed: true });
|
||||||
|
assert.deepStrictEqual(store.removeTag(tag.id), { removed: false });
|
||||||
|
assert.deepStrictEqual(store.listTags(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renaming and deleting tags update every item atomically without deleting books', () => {
|
||||||
|
freshRoot('propagation');
|
||||||
|
let changes = 0;
|
||||||
|
store.setChangeListener(() => { changes++; });
|
||||||
|
try {
|
||||||
|
const tag = store.addTag('Work');
|
||||||
|
const first = store.add({ title: '一', tags: ['work', 'Other'] });
|
||||||
|
const second = store.add({ title: '二', tags: ['WORK'] });
|
||||||
|
assert.strictEqual(store.listTags().find((entry) => entry.id === tag.id).count, 2);
|
||||||
|
|
||||||
|
const renamed = store.updateTag(tag.id, { name: 'Research' });
|
||||||
|
assert.strictEqual(renamed.name, 'Research');
|
||||||
|
assert.deepStrictEqual(store.get(first.id).tags, ['Research', 'Other']);
|
||||||
|
assert.deepStrictEqual(store.get(second.id).tags, ['Research']);
|
||||||
|
assert.strictEqual(store.listTags().find((entry) => entry.id === tag.id).count, 2);
|
||||||
|
assert.ok(!store.listTags().some((entry) => entry.name.toLowerCase() === 'work'));
|
||||||
|
|
||||||
|
assert.deepStrictEqual(store.removeTag(tag.id), { removed: true });
|
||||||
|
assert.strictEqual(store.list().length, 2);
|
||||||
|
assert.deepStrictEqual(store.get(first.id).tags, ['Other']);
|
||||||
|
assert.deepStrictEqual(store.get(second.id).tags, []);
|
||||||
|
assert.ok(store.listTags().some((entry) => entry.name === 'Other'));
|
||||||
|
assert.strictEqual(changes, 5, 'tag CRUD and tagged item organization changes should notify');
|
||||||
|
} finally {
|
||||||
|
store.setChangeListener(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add and update automatically catalog unseen item tags and retain them at zero use', () => {
|
||||||
|
const root = freshRoot('automatic');
|
||||||
|
const book = store.add({ title: '自动', tags: [' Alpha ', 'alpha'] });
|
||||||
|
store.update(book.id, { tags: ['Beta'] });
|
||||||
|
|
||||||
|
let listed = store.listTags();
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
listed.map((entry) => [entry.name, entry.count]),
|
||||||
|
[['Beta', 1], ['Alpha', 0]]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.remove(book.id, false);
|
||||||
|
listed = store.listTags();
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
listed.map((entry) => [entry.name, entry.count]).sort(),
|
||||||
|
[['Alpha', 0], ['Beta', 0]]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.init(root);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
store.listTags().map((entry) => [entry.name, entry.count]).sort(),
|
||||||
|
[['Alpha', 0], ['Beta', 0]]
|
||||||
|
);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
|
||||||
|
assert.deepStrictEqual(raw.items, []);
|
||||||
|
assert.deepStrictEqual(raw.tags.map((entry) => entry.name).sort(), ['Alpha', 'Beta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v1 through v3 indexes migrate to a normalized v4 tag catalog', () => {
|
||||||
|
const fixtures = [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
data: [{ id: 'v1', title: '一', tags: [' Alpha ', 'alpha'] }],
|
||||||
|
expected: 'Alpha'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
data: { version: 2, items: [{ id: 'v2', title: '二', tags: ['BETA'] }] },
|
||||||
|
expected: 'BETA'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
data: {
|
||||||
|
version: 3,
|
||||||
|
shelves: [],
|
||||||
|
items: [{ id: 'v3', title: '三', tags: [' 伽马 ', '伽马'] }]
|
||||||
|
},
|
||||||
|
expected: '伽马'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
const root = freshRoot(`schema-v${fixture.version}`);
|
||||||
|
fs.writeFileSync(indexPath(root), JSON.stringify(fixture.data));
|
||||||
|
store.init(root);
|
||||||
|
const migrated = store.listTags();
|
||||||
|
assert.strictEqual(migrated.length, 1);
|
||||||
|
assert.strictEqual(migrated[0].name, fixture.expected);
|
||||||
|
assert.strictEqual(migrated[0].count, 1);
|
||||||
|
|
||||||
|
store.addTag(`零使用-${fixture.version}`);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
|
||||||
|
assert.strictEqual(raw.version, 4);
|
||||||
|
assert.strictEqual(raw.tags.length, 2);
|
||||||
|
assert.strictEqual(raw.tags[0].name, fixture.expected);
|
||||||
|
assert.match(raw.tags[0].id, /^tag_[a-f0-9]{24}$/);
|
||||||
|
assert.deepStrictEqual(raw.items[0].tags, [fixture.expected]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy import merges managed and item tags case-insensitively', () => {
|
||||||
|
const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-library-legacy-'));
|
||||||
|
created.push(legacy);
|
||||||
|
fs.writeFileSync(indexPath(legacy), JSON.stringify({
|
||||||
|
version: 4,
|
||||||
|
shelves: [],
|
||||||
|
tags: [
|
||||||
|
{ id: 'legacy-existing', name: 'existing', createdAt: 10, updatedAt: 20 },
|
||||||
|
{ id: 'legacy-zero', name: 'Legacy Zero', createdAt: 30, updatedAt: 40 }
|
||||||
|
],
|
||||||
|
items: [{ id: 'legacy-book', title: '旧书', tags: ['Imported Item'] }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
freshRoot('legacy-destination');
|
||||||
|
const existing = store.addTag('Existing');
|
||||||
|
assert.strictEqual(store.importLegacy(legacy).imported, 1);
|
||||||
|
|
||||||
|
const listed = store.listTags();
|
||||||
|
assert.strictEqual(listed.filter((entry) => entry.name.toLowerCase() === 'existing').length, 1);
|
||||||
|
assert.strictEqual(listed.find((entry) => entry.id === existing.id).name, 'Existing');
|
||||||
|
assert.strictEqual(listed.find((entry) => entry.name === 'Legacy Zero').count, 0);
|
||||||
|
assert.strictEqual(listed.find((entry) => entry.name === 'Imported Item').count, 1);
|
||||||
|
assert.strictEqual(store.get('legacy-book').tags[0], 'Imported Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listTags is deterministically ordered and deeply cloned', () => {
|
||||||
|
freshRoot('list');
|
||||||
|
store.addTag('零');
|
||||||
|
store.add({ title: '一', tags: ['Zulu', '中文'] });
|
||||||
|
store.add({ title: '二', tags: ['zulu', 'Alpha'] });
|
||||||
|
|
||||||
|
const listed = store.listTags();
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
listed.map((entry) => [entry.name, entry.count]),
|
||||||
|
[
|
||||||
|
['Zulu', 2],
|
||||||
|
...[
|
||||||
|
['Alpha', 1],
|
||||||
|
['中文', 1]
|
||||||
|
].sort((a, b) => a[0].localeCompare(b[0], 'zh-CN', { sensitivity: 'base' })),
|
||||||
|
['零', 0]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
listed[0].name = '外部修改';
|
||||||
|
listed[0].count = 999;
|
||||||
|
listed.push({ id: 'fake', name: '假的', count: 1 });
|
||||||
|
const again = store.listTags();
|
||||||
|
assert.strictEqual(again.length, 4);
|
||||||
|
assert.strictEqual(again[0].name, 'Zulu');
|
||||||
|
assert.strictEqual(again[0].count, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed tag writes roll back both catalog and item references', () => {
|
||||||
|
const root = freshRoot('rollback');
|
||||||
|
const tag = store.addTag('Before');
|
||||||
|
const book = store.add({ title: '书', tags: ['before'] });
|
||||||
|
const file = indexPath(root);
|
||||||
|
const beforeFile = fs.readFileSync(file, 'utf8');
|
||||||
|
const beforeTags = store.listTags();
|
||||||
|
const originalRename = fs.renameSync;
|
||||||
|
let failed = false;
|
||||||
|
fs.renameSync = function renameWithFailure(source, destination) {
|
||||||
|
if (!failed && source === `${file}.tmp` && destination === file) {
|
||||||
|
failed = true;
|
||||||
|
throw new Error('simulated replace failure');
|
||||||
|
}
|
||||||
|
return originalRename.apply(this, arguments);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() => store.updateTag(tag.id, { name: 'After' }),
|
||||||
|
/书库索引写入失败/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.renameSync = originalRename;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(failed);
|
||||||
|
assert.strictEqual(fs.readFileSync(file, 'utf8'), beforeFile);
|
||||||
|
assert.deepStrictEqual(store.listTags(), beforeTags);
|
||||||
|
assert.deepStrictEqual(store.get(book.id).tags, ['before']);
|
||||||
|
assert.ok(!fs.existsSync(`${file}.tmp`));
|
||||||
|
assert.ok(!fs.existsSync(`${file}.bak`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('批量更新只写一次索引,任一条目无效则整批回滚', () => {
|
||||||
|
const root = freshRoot('update-many');
|
||||||
|
const a = store.add({ title: '甲', tags: ['共有'] });
|
||||||
|
const b = store.add({ title: '乙', tags: ['共有', '仅乙'] });
|
||||||
|
const c = store.add({ title: '丙', tags: [] });
|
||||||
|
const file = indexPath(root);
|
||||||
|
|
||||||
|
// 逐条 update 会把整个索引重写 N 遍,批量必须收敛成一次。
|
||||||
|
// 索引经 fd 写入,因此统计 open 而不是 writeFileSync 的路径参数。
|
||||||
|
const countIndexWrites = (fn) => {
|
||||||
|
const originalOpen = fs.openSync;
|
||||||
|
let writes = 0;
|
||||||
|
fs.openSync = function counting(target, flags, ...rest) {
|
||||||
|
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
|
||||||
|
return originalOpen.call(this, target, flags, ...rest);
|
||||||
|
};
|
||||||
|
try { fn(); } finally { fs.openSync = originalOpen; }
|
||||||
|
return writes;
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
const writes = countIndexWrites(() => {
|
||||||
|
updated = store.updateMany([
|
||||||
|
{ id: a.id, patch: { tags: ['共有', '新增'] } },
|
||||||
|
{ id: b.id, patch: { shelfId: null, tags: ['共有'] } }
|
||||||
|
]).updated;
|
||||||
|
});
|
||||||
|
assert.strictEqual(updated, 2);
|
||||||
|
assert.strictEqual(writes, 1, '批量更新应只写一次索引');
|
||||||
|
|
||||||
|
// 对照:逐条 update 会写两次,证明上面的 1 不是计数器失灵
|
||||||
|
const loopWrites = countIndexWrites(() => {
|
||||||
|
store.update(a.id, { tags: ['共有', '新增'] });
|
||||||
|
store.update(b.id, { tags: ['共有'] });
|
||||||
|
});
|
||||||
|
assert.strictEqual(loopWrites, 2, '逐条更新应写两次,用于对照');
|
||||||
|
|
||||||
|
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
|
||||||
|
assert.deepStrictEqual(store.get(b.id).tags, ['共有']);
|
||||||
|
assert.deepStrictEqual(store.get(c.id).tags, [], '未列出的条目不应被改动');
|
||||||
|
|
||||||
|
const snapshot = fs.readFileSync(file, 'utf8');
|
||||||
|
assert.throws(() => store.updateMany([
|
||||||
|
{ id: a.id, patch: { tags: ['不该生效'] } },
|
||||||
|
{ id: 'missing-id', patch: { tags: ['x'] } }
|
||||||
|
]), /条目不存在/);
|
||||||
|
assert.strictEqual(fs.readFileSync(file, 'utf8'), snapshot, '整批回滚不应留下部分写入');
|
||||||
|
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
|
||||||
|
assert.throws(() => store.updateMany([{ patch: {} }]), /条目 ID/);
|
||||||
|
assert.deepStrictEqual(store.updateMany([]), { updated: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('批量移除只写一次索引,可选删除库内文件', () => {
|
||||||
|
const root = freshRoot('remove-many');
|
||||||
|
fs.mkdirSync(path.join(root, 'files'), { recursive: true });
|
||||||
|
const made = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const abs = path.join(root, 'files', `book-${i}.pdf`);
|
||||||
|
fs.writeFileSync(abs, '%PDF-1.4\n');
|
||||||
|
made.push(store.add({ title: `书${i}`, files: [{ path: abs, name: `book-${i}.pdf` }] }));
|
||||||
|
}
|
||||||
|
const outside = path.join(root, '..', `outside-${path.basename(root)}.pdf`);
|
||||||
|
fs.writeFileSync(outside, '%PDF-1.4\n');
|
||||||
|
created.push(outside);
|
||||||
|
const external = store.add({ title: '外部', files: [{ path: outside, name: 'outside.pdf' }] });
|
||||||
|
const file = indexPath(root);
|
||||||
|
|
||||||
|
const originalOpen = fs.openSync;
|
||||||
|
let writes = 0;
|
||||||
|
fs.openSync = function counting(target, flags, ...rest) {
|
||||||
|
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
|
||||||
|
return originalOpen.call(this, target, flags, ...rest);
|
||||||
|
};
|
||||||
|
let removed = 0;
|
||||||
|
try {
|
||||||
|
removed = store.removeMany([made[0].id, made[1].id], true).removed;
|
||||||
|
} finally {
|
||||||
|
fs.openSync = originalOpen;
|
||||||
|
}
|
||||||
|
assert.strictEqual(removed, 2);
|
||||||
|
assert.strictEqual(writes, 1, '批量移除应只写一次索引');
|
||||||
|
assert.strictEqual(store.list().length, 2);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-0.pdf')), false);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-2.pdf')), true);
|
||||||
|
|
||||||
|
// 用户原地引用的库外文件不能被删
|
||||||
|
assert.strictEqual(store.removeMany([external.id], true).removed, 1);
|
||||||
|
assert.strictEqual(fs.existsSync(outside), true, '库外文件不应被删除');
|
||||||
|
assert.deepStrictEqual(store.removeMany([], true), { removed: 0 });
|
||||||
|
assert.deepStrictEqual(store.removeMany(['missing'], false), { removed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit tag creation enforces the existing catalog limit', () => {
|
||||||
|
const root = freshRoot('limit');
|
||||||
|
const now = Date.now();
|
||||||
|
fs.writeFileSync(indexPath(root), JSON.stringify({
|
||||||
|
version: 4,
|
||||||
|
shelves: [],
|
||||||
|
tags: Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: `tag-seeded-${i}`,
|
||||||
|
name: `Tag ${i}`,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})),
|
||||||
|
items: []
|
||||||
|
}));
|
||||||
|
store.init(root);
|
||||||
|
assert.throws(() => store.addTag('One Too Many'), /50/);
|
||||||
|
assert.strictEqual(store.listTags().length, 50);
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { discover } = require('../library/local-import');
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
|
||||||
|
function freshRoot(tag) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-local-import-${tag}-`));
|
||||||
|
created.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(root, relativePath, contents = '') {
|
||||||
|
const target = path.join(root, relativePath);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, contents);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
for (const root of created) {
|
||||||
|
try { fs.rmSync(root, { recursive: true, force: true }); } catch (error) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discovers a directly selected supported file with a canonical record', async () => {
|
||||||
|
const root = freshRoot('direct');
|
||||||
|
const selected = write(root, 'A Book.PDF');
|
||||||
|
const canonical = fs.realpathSync(selected);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(await discover([selected]), [{
|
||||||
|
path: canonical,
|
||||||
|
name: 'A Book.PDF',
|
||||||
|
format: 'pdf',
|
||||||
|
parentName: path.basename(root)
|
||||||
|
}]);
|
||||||
|
assert.ok(path.isAbsolute(canonical));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recursively discovers supported files and uses each immediate parent name', async () => {
|
||||||
|
const root = freshRoot('recursive');
|
||||||
|
const first = write(root, 'root.epub');
|
||||||
|
const second = write(root, path.join('Shelf One', 'nested.MOBI'));
|
||||||
|
const third = write(root, path.join('Shelf One', 'Deeper', 'last.fb2'));
|
||||||
|
|
||||||
|
const result = await discover([root]);
|
||||||
|
const byName = new Map(result.map((record) => [record.name, record]));
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
new Set(result.map((record) => record.path)),
|
||||||
|
new Set([first, second, third].map((value) => fs.realpathSync(value)))
|
||||||
|
);
|
||||||
|
assert.strictEqual(byName.get('root.epub').parentName, path.basename(root));
|
||||||
|
assert.strictEqual(byName.get('nested.MOBI').parentName, 'Shelf One');
|
||||||
|
assert.strictEqual(byName.get('last.fb2').parentName, 'Deeper');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
Object.fromEntries(result.map((record) => [record.name, record.format])),
|
||||||
|
{ 'last.fb2': 'fb2', 'nested.MOBI': 'mobi', 'root.epub': 'epub' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles mixed file and directory inputs while skipping unsupported and non-files', async () => {
|
||||||
|
const root = freshRoot('mixed');
|
||||||
|
const folder = path.join(root, 'folder');
|
||||||
|
const inFolder = write(root, path.join('folder', 'comic.cbz'));
|
||||||
|
const azw = write(root, path.join('folder', 'legacy.azw'));
|
||||||
|
const direct = write(root, 'notes.txt');
|
||||||
|
const markdown = write(root, path.join('folder', 'guide.MD'));
|
||||||
|
write(root, path.join('folder', 'cover.jpg'));
|
||||||
|
write(root, 'README.rtf');
|
||||||
|
fs.mkdirSync(path.join(root, 'empty'));
|
||||||
|
|
||||||
|
const result = await discover([
|
||||||
|
path.join(root, 'missing.pdf'),
|
||||||
|
path.join(root, 'README.rtf'),
|
||||||
|
path.join(root, 'empty'),
|
||||||
|
direct,
|
||||||
|
folder
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
result.map((record) => record.path),
|
||||||
|
[inFolder, azw, markdown, direct].map((value) => fs.realpathSync(value)).sort(),
|
||||||
|
'md 与 txt 都能进内置阅读器,必须和其他图书格式一样被本地导入发现'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('de-duplicates repeated selections', async () => {
|
||||||
|
const root = freshRoot('duplicate');
|
||||||
|
const selected = write(root, 'duplicate.djvu');
|
||||||
|
|
||||||
|
const result = await discover([selected, root, selected]);
|
||||||
|
assert.strictEqual(result.length, 1);
|
||||||
|
assert.strictEqual(result[0].path, fs.realpathSync(selected));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not follow symbolic links to files or directories when links are available', async (t) => {
|
||||||
|
const root = freshRoot('symlink');
|
||||||
|
const outside = freshRoot('symlink-target');
|
||||||
|
const ordinary = write(root, 'ordinary.cbr');
|
||||||
|
const linkedFileTarget = write(outside, 'linked.pdf');
|
||||||
|
const linkedDirectoryTarget = path.join(outside, 'books');
|
||||||
|
const nestedTarget = write(outside, path.join('books', 'nested.azw3'));
|
||||||
|
const fileLink = path.join(root, 'file-link.pdf');
|
||||||
|
const directoryLink = path.join(root, 'directory-link');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(linkedFileTarget, fileLink, 'file');
|
||||||
|
fs.symlinkSync(
|
||||||
|
linkedDirectoryTarget,
|
||||||
|
directoryLink,
|
||||||
|
process.platform === 'win32' ? 'junction' : 'dir'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
t.skip(`symbolic links are unavailable: ${error.code || error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await discover([root, fileLink, directoryLink]);
|
||||||
|
assert.deepStrictEqual(result.map((record) => record.path), [fs.realpathSync(ordinary)]);
|
||||||
|
assert.ok(!result.some((record) => record.path === fs.realpathSync(nestedTarget)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a deterministic path-sorted order independent of selection order', async () => {
|
||||||
|
const root = freshRoot('order');
|
||||||
|
write(root, 'zeta.txt');
|
||||||
|
write(root, 'Alpha.pdf');
|
||||||
|
write(root, path.join('middle', 'beta.epub'));
|
||||||
|
|
||||||
|
const forward = await discover([path.join(root, 'zeta.txt'), path.join(root, 'middle'), root]);
|
||||||
|
const reverse = await discover([root, path.join(root, 'middle'), path.join(root, 'zeta.txt')]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(forward, reverse);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
forward.map((record) => record.path),
|
||||||
|
forward.map((record) => record.path).slice().sort((left, right) => {
|
||||||
|
const leftKey = process.platform === 'win32' ? left.toLowerCase() : left;
|
||||||
|
const rightKey = process.platform === 'win32' ? right.toLowerCase() : right;
|
||||||
|
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left < right ? -1 : left > right ? 1 : 0;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws a clear Chinese error when the supported-file maximum is exceeded', async () => {
|
||||||
|
const root = freshRoot('maximum');
|
||||||
|
write(root, 'one.pdf');
|
||||||
|
write(root, 'two.epub');
|
||||||
|
write(root, 'three.mobi');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
discover([root], { maxFiles: 2 }),
|
||||||
|
/本地导入文件数量超过上限(最多 2 个)/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const h = require('./helpers');
|
||||||
|
|
||||||
|
const mainFile = path.join(__dirname, '..', '..', 'main.js');
|
||||||
|
const mainSrc = fs.readFileSync(mainFile, 'utf8');
|
||||||
|
|
||||||
|
const { compareVersion } = h.extractFns(
|
||||||
|
mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion']
|
||||||
|
);
|
||||||
|
const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']);
|
||||||
|
|
||||||
|
test('wrap 捕获同步抛出,不让 invoke reject', async () => {
|
||||||
|
const r = await wrap(() => { throw new Error('未知数据源: nope'); });
|
||||||
|
assert.deepStrictEqual(r, { ok: false, error: '未知数据源: nope' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrap 捕获异步拒绝', async () => {
|
||||||
|
const r = await wrap(() => Promise.reject(new Error('boom')));
|
||||||
|
assert.strictEqual(r.ok, false);
|
||||||
|
assert.strictEqual(r.error, 'boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrap 正常返回包成 { ok:true, data }', async () => {
|
||||||
|
assert.deepStrictEqual(await wrap(() => 42), { ok: true, data: 42 });
|
||||||
|
assert.deepStrictEqual(await wrap(() => Promise.resolve('x')), { ok: true, data: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrap 对非 Error 抛出也能给出字符串', async () => {
|
||||||
|
const r = await wrap(() => { throw 'plain string'; });
|
||||||
|
assert.strictEqual(r.ok, false);
|
||||||
|
assert.strictEqual(r.error, 'plain string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
|
||||||
|
assert.ok(/function wrap\(fn\)/.test(mainSrc), 'wrap 未改成接收函数');
|
||||||
|
assert.ok(!/wrap\(sources\.getSource/.test(mainSrc), '仍有同步求值的 getSource 传进 wrap');
|
||||||
|
assert.ok(!/wrap\(Promise\.resolve/.test(mainSrc), '仍有 Promise.resolve 被提前求值');
|
||||||
|
// 直接返回 { ok: true, ... } 而不过 wrap 的 handler 会绕开错误处理
|
||||||
|
const bare = mainSrc.match(/ipcMain\.handle\([^)]*=>\s*\(\{\s*ok:\s*true/g) || [];
|
||||||
|
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('版本比较:预发布版本低于同号正式版', () => {
|
||||||
|
assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
|
||||||
|
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
|
||||||
|
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0-beta'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('版本比较:常规大小与位数不等', () => {
|
||||||
|
assert.strictEqual(compareVersion('1.2.0', '1.1.9'), 1);
|
||||||
|
assert.strictEqual(compareVersion('1.1.0', '1.1.0'), 0);
|
||||||
|
assert.strictEqual(compareVersion('2.0', '1.9.9'), 1);
|
||||||
|
assert.strictEqual(compareVersion('1.10.0', '1.9.0'), 1, '按数值而非字典序比较');
|
||||||
|
assert.strictEqual(compareVersion('v1.1.1', '1.1.0'), 1, '应容忍 v 前缀');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口控制 handler 检查 isDestroyed', () => {
|
||||||
|
assert.ok(/function liveWindow\(\)/.test(mainSrc), '缺少 liveWindow 守卫');
|
||||||
|
assert.ok(/isDestroyed\(\)\s*\?\s*null\s*:\s*mainWindow/.test(mainSrc.replace(/\s+/g, ' ')) ||
|
||||||
|
/!mainWindow\.isDestroyed\(\)/.test(mainSrc), 'liveWindow 未检查 isDestroyed');
|
||||||
|
assert.ok(!/mainWindow && mainWindow\.minimize\(\)/.test(mainSrc), '仍有未加守卫的窗口调用');
|
||||||
|
assert.ok(!/dialog\.show\w+\(mainWindow,/.test(mainSrc), '对话框仍直接引用可能已销毁的窗口');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载校验协议,拒绝 file:// 等非 http(s)', () => {
|
||||||
|
assert.ok(/仅支持 HTTP 或 HTTPS 下载链接/.test(mainSrc));
|
||||||
|
assert.ok(/仅允许打开 HTTP 或 HTTPS 链接/.test(mainSrc), 'openExternal 缺协议校验');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载请求使用可识别且带项目地址的 User-Agent', () => {
|
||||||
|
assert.match(mainSrc, /UA:\s*DL_UA[\s\S]+require\('\.\/src\/sources\/http'\)/);
|
||||||
|
const httpSrc = fs.readFileSync(path.join(__dirname, '..', 'sources', 'http.js'), 'utf8');
|
||||||
|
assert.match(httpSrc, /PeopleLib\/2\.1\.0 \(\+https:\/\/github\.com\/lofyer\/peoplelib\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('library:remove'");
|
||||||
|
const end = mainSrc.indexOf('// 下载文件', start);
|
||||||
|
const segment = mainSrc.slice(start, end);
|
||||||
|
assert.match(segment, /options\.deleteReadingData\s*===\s*true/);
|
||||||
|
const guard = segment.indexOf('if (deleteReadingData)');
|
||||||
|
assert.ok(guard >= 0, '缺少显式清理守卫');
|
||||||
|
assert.ok(segment.indexOf('readerStore.forget', guard) > guard);
|
||||||
|
assert.ok(segment.indexOf('annotations.forget', guard) > guard);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('批量整理与移除走单次索引提交,不按条目循环 IPC', () => {
|
||||||
|
for (const [channel, method] of [['library:updateMany', 'updateMany'], ['library:removeMany', 'removeMany']]) {
|
||||||
|
const start = mainSrc.indexOf(`ipcMain.handle('${channel}'`);
|
||||||
|
assert.ok(start > 0, `缺少 ${channel}`);
|
||||||
|
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||||
|
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||||
|
assert.ok(segment.includes(`library.${method}(`), `${channel} 应调用 library.${method}`);
|
||||||
|
}
|
||||||
|
const removeStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
|
||||||
|
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
|
||||||
|
const removeSegment = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
|
||||||
|
// 与单本移除一致:默认保留阅读资料,只有显式勾选才清理
|
||||||
|
assert.match(removeSegment, /deleteReadingData\s*===\s*true/);
|
||||||
|
|
||||||
|
const uiSrc = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'library.js'), 'utf8');
|
||||||
|
assert.match(uiSrc, /window\.api\.library\.updateMany\(/);
|
||||||
|
assert.match(uiSrc, /window\.api\.library\.removeMany\(/);
|
||||||
|
assert.ok(!/for\s*\(const item of targets\)[\s\S]{0,200}library\.(update|remove)\(/.test(uiSrc),
|
||||||
|
'渲染层不应再逐条发 IPC');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('孤立阅读资料只报告不自动删除,清理需分别勾选', () => {
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
|
||||||
|
assert.ok(start > 0, '缺少孤立资料对账通道');
|
||||||
|
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||||
|
const report = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||||
|
// 对账必须以真实书库条目为准,不能凭文件名猜
|
||||||
|
assert.match(report, /library\.list\(\)/);
|
||||||
|
assert.match(report, /readerStore\.orphanReport\(/);
|
||||||
|
assert.match(report, /annotations\.orphanReport\(/);
|
||||||
|
|
||||||
|
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
|
||||||
|
assert.ok(purgeStart > 0, '缺少孤立资料清理通道');
|
||||||
|
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
|
||||||
|
const purge = mainSrc.slice(purgeStart, purgeEnd < 0 ? undefined : purgeEnd);
|
||||||
|
assert.match(purge, /scope\.annotations\s*===\s*true/);
|
||||||
|
assert.match(purge, /scope\.notes\s*===\s*true/);
|
||||||
|
assert.match(purge, /scope\.chats\s*===\s*true/);
|
||||||
|
assert.ok(purge.indexOf('library.list()') >= 0, '清理前必须重新对账,不能信任渲染层传来的 ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话随书籍删除一并清理,孤立会话纳入对账', () => {
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
|
||||||
|
assert.ok(start > 0, '缺少批量移除通道');
|
||||||
|
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||||
|
const remove = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||||
|
assert.match(remove, /aiSessions\.forgetMany\(/);
|
||||||
|
// 会话删完要回收图片,否则内容寻址的图片永远没有引用者来释放
|
||||||
|
assert.match(remove, /collectAiImages\(\)/);
|
||||||
|
|
||||||
|
const reportStart = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
|
||||||
|
const reportEnd = mainSrc.indexOf('ipcMain.handle(', reportStart + 20);
|
||||||
|
assert.match(mainSrc.slice(reportStart, reportEnd), /aiSessions\.orphanReport\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记独立窗口:开窗前重新对账,窗口内只能读自己已开的标签', () => {
|
||||||
|
const openStart = mainSrc.indexOf("ipcMain.handle('notes:openWindow'");
|
||||||
|
assert.ok(openStart > 0, '缺少笔记开窗通道');
|
||||||
|
const openEnd = mainSrc.indexOf('ipcMain.handle(', openStart + 20);
|
||||||
|
const open = mainSrc.slice(openStart, openEnd < 0 ? undefined : openEnd);
|
||||||
|
// 目标由 findNote 重新对账,不能直接把渲染层传来的 ID 拿去开窗
|
||||||
|
assert.match(open, /findNote\(entryId,\s*noteId\)/);
|
||||||
|
assert.match(open, /noteWindow\.open\(note\.entryId,\s*note\.id/);
|
||||||
|
|
||||||
|
const findStart = mainSrc.indexOf('function findNote');
|
||||||
|
const find = mainSrc.slice(findStart, findStart + 500);
|
||||||
|
assert.match(find, /readerStore\.listNotes\(/, '对账必须以 store 里真实存在的笔记为准');
|
||||||
|
|
||||||
|
const getStart = mainSrc.indexOf("ipcMain.handle('notes:getOne'");
|
||||||
|
const getEnd = mainSrc.indexOf('ipcMain.handle(', getStart + 20);
|
||||||
|
const getOne = mainSrc.slice(getStart, getEnd < 0 ? undefined : getEnd);
|
||||||
|
// 多标签后授权是「在标签集内」,不是「等于某一条」
|
||||||
|
assert.match(getOne, /noteWindow\.ownsNote\(event\.sender,\s*noteId\)/);
|
||||||
|
assert.match(getOne, /无权读取其它笔记/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记标签集只能由渲染层收窄,新增必须走 open 对账', () => {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
|
||||||
|
const start = src.indexOf('function setTabs');
|
||||||
|
assert.ok(start > 0, '缺少 setTabs');
|
||||||
|
const body = src.slice(start, src.indexOf('\n}', start));
|
||||||
|
// 允许渲染层往标签集里塞 ID,等于让它自己扩权:
|
||||||
|
// 谎报持有某条笔记后 notes:getOne 就会放行
|
||||||
|
assert.doesNotMatch(body, /openNotes\.set\(/, 'setTabs 不能新增标签');
|
||||||
|
assert.match(body, /openNotes\.delete\(/, 'setTabs 只做收窄');
|
||||||
|
assert.match(body, /isNoteSender\(wc\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记窗口取消关闭后要复位 closePending 并撤掉看门狗', () => {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
|
||||||
|
const start = src.indexOf('function cancelClose');
|
||||||
|
assert.ok(start > 0, '缺少 cancelClose:取消后窗口会再也关不掉');
|
||||||
|
const body = src.slice(start, src.indexOf('\n}', start));
|
||||||
|
assert.match(body, /clearTimeout\(closeTimer\)/, '不撤看门狗会在十秒后销毁带未保存内容的窗口');
|
||||||
|
assert.match(body, /closePending = false/);
|
||||||
|
assert.match(src, /ipcMain|module\.exports[\s\S]*cancelClose/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记删除或书籍移除后独立窗口必须退场', () => {
|
||||||
|
const removeStart = mainSrc.indexOf("ipcMain.handle('reader:removeNote'");
|
||||||
|
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
|
||||||
|
const remove = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
|
||||||
|
// 窗口留着的话,它下一次保存会把已经删掉的笔记整条写回去
|
||||||
|
assert.match(remove, /noteWindow\.closeFor\(noteId\)/);
|
||||||
|
|
||||||
|
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
|
||||||
|
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
|
||||||
|
assert.match(mainSrc.slice(purgeStart, purgeEnd), /noteWindow\.closeForEntries\(/);
|
||||||
|
|
||||||
|
const removeManyStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
|
||||||
|
const removeManyEnd = mainSrc.indexOf('ipcMain.handle(', removeManyStart + 20);
|
||||||
|
assert.match(mainSrc.slice(removeManyStart, removeManyEnd), /noteWindow\.closeForEntries\(list\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('三处窗口广播都覆盖笔记独立窗口', () => {
|
||||||
|
// 漏掉任意一处,笔记窗口就收不到笔记变更或主题切换,界面与其它窗口不一致
|
||||||
|
for (const fn of ['notifyNotesChanged', 'applyWindowIcons', 'notifyUiThemeChanged']) {
|
||||||
|
const start = mainSrc.indexOf(`function ${fn}(`);
|
||||||
|
assert.ok(start > 0, `缺少 ${fn}`);
|
||||||
|
const body = mainSrc.slice(start, start + 420);
|
||||||
|
assert.match(body, /noteWindow\.all\(\)/, `${fn} 未覆盖笔记窗口`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话按轮次落盘:先取历史再写提问,失败与取消都保留残片', () => {
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('ai:run'");
|
||||||
|
assert.ok(start > 0, '缺少 AI 运行通道');
|
||||||
|
const end = mainSrc.indexOf('function settleAiAssistant', start);
|
||||||
|
const run = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||||
|
|
||||||
|
// 顺序是硬约束:先 historyFor 再 appendUser,反了当前提问会被当成自己的历史发两遍
|
||||||
|
const historyAt = run.indexOf('historyFor(');
|
||||||
|
const appendAt = run.indexOf('appendUser(');
|
||||||
|
assert.ok(historyAt > 0 && appendAt > historyAt, '必须在写入本轮提问之前取历史');
|
||||||
|
|
||||||
|
// 同一会话禁止并发,否则两轮同时写同一个文件会互相覆盖
|
||||||
|
assert.match(run, /run\.sessionId\s*===\s*chatId/);
|
||||||
|
|
||||||
|
// 存的是提问本身,不是整篇正文
|
||||||
|
assert.match(run, /text:\s*aiTurnTitle\(task,\s*question\)/);
|
||||||
|
assert.match(run, /hash:\s*aiSessions\.hashContext\(body\)/);
|
||||||
|
|
||||||
|
// 已经流出来的残片必须落盘,界面上看到的半截回答不能一重开就消失
|
||||||
|
assert.match(run, /streamed\s*\+=\s*piece/);
|
||||||
|
assert.match(run, /text:\s*streamed/);
|
||||||
|
assert.ok(!/text:\s*''\s*,\s*\n\s*cancelled/.test(run), '取消时不能把残片写成空串');
|
||||||
|
|
||||||
|
// 落盘失败不能把已经拿到的回答变成请求失败
|
||||||
|
const settleStart = mainSrc.indexOf('function settleAiAssistant');
|
||||||
|
const settle = mainSrc.slice(settleStart, settleStart + 400);
|
||||||
|
assert.match(settle, /try\s*\{[\s\S]*finishAssistant\([\s\S]*catch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('阅读器关闭书籍后通知书库刷新,且只接受阅读器发来的上报', () => {
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('reader:entryClosed'");
|
||||||
|
assert.ok(start > 0, '缺少关闭上报通道');
|
||||||
|
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||||
|
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||||
|
assert.match(segment, /isReaderSender\(event\.sender\)/);
|
||||||
|
assert.match(segment, /notifyLibraryChanged\(\)/);
|
||||||
|
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
// 进度与批注异步落盘,先排空再通知,否则书库读到的还是旧数据
|
||||||
|
const drainTab = shell.indexOf('async function closeTab');
|
||||||
|
const drainEnd = shell.indexOf('\nasync function', drainTab + 20);
|
||||||
|
const closeTabBody = shell.slice(drainTab, drainEnd < 0 ? undefined : drainEnd);
|
||||||
|
assert.ok(closeTabBody.indexOf('await drainTabWrites(tab)') > 0);
|
||||||
|
assert.ok(closeTabBody.indexOf('notifyEntryClosed()') > closeTabBody.indexOf('await drainTabWrites(tab)'),
|
||||||
|
'通知必须排在写入排空之后');
|
||||||
|
|
||||||
|
const drainAll = shell.slice(shell.indexOf('async function drainAllTabWrites'));
|
||||||
|
assert.ok(drainAll.indexOf('await drainTabWrites(tab)') > 0);
|
||||||
|
assert.ok(drainAll.indexOf('notifyEntryClosed()') > drainAll.indexOf('await drainTabWrites(tab)'),
|
||||||
|
'关闭整个窗口也要在排空后通知');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书库列表附带阅读记录中的最近阅读时间', () => {
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('library:list'");
|
||||||
|
const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
|
||||||
|
const segment = mainSrc.slice(start, end);
|
||||||
|
assert.match(segment, /lastReadAt:\s*readerStore\.getLastReadAt\(item\.id\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Z-Library 登录通过受限同源浏览器完成反机器人验证', () => {
|
||||||
|
const start = mainSrc.indexOf('async function browserZlibLogin');
|
||||||
|
const end = mainSrc.indexOf('// Z-Library 凭据', start);
|
||||||
|
const segment = mainSrc.slice(start, end);
|
||||||
|
assert.ok(start > 0 && end > start);
|
||||||
|
assert.match(segment, /show:\s*false/);
|
||||||
|
assert.match(segment, /contextIsolation:\s*true/);
|
||||||
|
assert.match(segment, /nodeIntegration:\s*false/);
|
||||||
|
assert.match(segment, /sandbox:\s*true/);
|
||||||
|
assert.match(segment, /setWindowOpenHandler\(\(\)\s*=>\s*\(\{\s*action:\s*'deny'/);
|
||||||
|
assert.match(segment, /new URL\(target\)\.origin\s*!==\s*origin/);
|
||||||
|
assert.match(segment, /executeJavaScriptInIsolatedWorld/);
|
||||||
|
assert.match(segment, /session\.defaultSession\.cookies\.get/);
|
||||||
|
assert.match(segment, /if \(!win\.isDestroyed\(\)\) win\.destroy\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => {
|
||||||
|
assert.match(mainSrc, /event\.sender\.send\('download:progress'/);
|
||||||
|
assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/);
|
||||||
|
assert.match(mainSrc, /percent:\s*totalBytes\s*\?\s*Math\.min\(1,\s*receivedBytes\s*\/\s*totalBytes\)\s*:\s*null/);
|
||||||
|
assert.match(mainSrc, /percent:\s*1,\s*complete:\s*true/);
|
||||||
|
assert.match(mainSrc, /headers\['Range'\]\s*=\s*`bytes=\$\{resumeBytes\}-`/);
|
||||||
|
assert.match(mainSrc, /res\.status\s*===\s*206/);
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('download:pause'/);
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('download:delete'/);
|
||||||
|
assert.match(mainSrc, /downloadSessionKey\(event\.sender\.id,\s*id\)/);
|
||||||
|
assert.match(mainSrc, /removeDownloadPartial\(download\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口使用 icons/dist 主题图标并同步界面主题', () => {
|
||||||
|
const iconDir = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||||||
|
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||||
|
const bytes = fs.readFileSync(path.join(iconDir, name));
|
||||||
|
assert.deepStrictEqual([...bytes.subarray(0, 4)], [0, 0, 1, 0], `${name} 不是 ICO`);
|
||||||
|
}
|
||||||
|
assert.match(mainSrc, /function iconForTheme\(theme\)/);
|
||||||
|
assert.match(mainSrc, /icon:\s*iconForTheme\(currentUiTheme\)/);
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('ui:setTheme'/);
|
||||||
|
assert.match(mainSrc, /settings\.set\('ui\.theme', theme\)/);
|
||||||
|
assert.match(mainSrc, /settings\.set\('reader\.uiTheme', theme\)/);
|
||||||
|
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||||
|
assert.match(build, /rcedit\(executable,[\s\S]*book-ai-dark\.ico/);
|
||||||
|
assert.match(build, /book-ai-light\.ico/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('标准构建入口固定输出目录并保留便携数据', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const build = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
|
||||||
|
assert.strictEqual(pkg.scripts.build, 'node build-portable.js');
|
||||||
|
assert.match(build, /const OUT = REQUESTED_OUT/);
|
||||||
|
assert.match(build, /if \(entry\.name === 'data'\) continue/);
|
||||||
|
assert.match(build, /clearOutput\(OUT\)/);
|
||||||
|
assert.match(build, /请先关闭其中正在运行的/);
|
||||||
|
assert.doesNotMatch(build, /nextAvailableOutput|-rebuild/);
|
||||||
|
// 目录名固定为平台标识,升级版本不再产生新目录,data/ 也就不会被落在旧目录里
|
||||||
|
assert.match(build, /const TARGET = `\$\{PRODUCT\}-windows-x64`/);
|
||||||
|
assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadPathResolvers(platform, isPackaged) {
|
||||||
|
return h.extractFns(
|
||||||
|
mainFile,
|
||||||
|
'const APP_ICON_DIR =',
|
||||||
|
'const userDataDir = resolveUserDataDir()',
|
||||||
|
['iconForTheme', 'resolveUserDataDir'],
|
||||||
|
`const path = require('path');
|
||||||
|
const __dirname = ${JSON.stringify(path.join('/opt', 'app'))};
|
||||||
|
const process = { platform: ${JSON.stringify(platform)} };
|
||||||
|
const app = {
|
||||||
|
isPackaged: ${isPackaged},
|
||||||
|
getPath: (key) => {
|
||||||
|
if (key === 'exe') return ${JSON.stringify(path.join('/opt', 'portable', 'PeopleLib.exe'))};
|
||||||
|
if (key === 'appData') return ${JSON.stringify(path.join('/home', 'u', 'AppData'))};
|
||||||
|
throw new Error('未预期的 getPath: ' + key);
|
||||||
|
}
|
||||||
|
};`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('macOS 打包版把用户数据放到 appData,不写进只读的 .app 内部', () => {
|
||||||
|
const appData = path.join('/home', 'u', 'AppData', 'PeopleLib');
|
||||||
|
|
||||||
|
// macOS 上 exe 位于 PeopleLib.app/Contents/MacOS/,DMG 挂载只读,
|
||||||
|
// 且覆盖升级会连同用户书库一起删掉,所以绝不能落在可执行文件旁边
|
||||||
|
assert.strictEqual(loadPathResolvers('darwin', true).resolveUserDataDir(), appData);
|
||||||
|
assert.strictEqual(loadPathResolvers('linux', true).resolveUserDataDir(), appData);
|
||||||
|
|
||||||
|
// Windows 便携版仍旧放在程序同级 data/
|
||||||
|
assert.strictEqual(
|
||||||
|
loadPathResolvers('win32', true).resolveUserDataDir(),
|
||||||
|
path.join('/opt', 'portable', 'data')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const platform of ['darwin', 'win32']) {
|
||||||
|
assert.strictEqual(loadPathResolvers(platform, false).resolveUserDataDir(), appData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口图标按平台取用,macOS 不会拿到 .ico', () => {
|
||||||
|
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||||||
|
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const win = loadPathResolvers('win32', true).iconForTheme(theme);
|
||||||
|
assert.strictEqual(path.extname(win), '.ico');
|
||||||
|
assert.strictEqual(path.basename(win), `book-ai-${theme}.ico`);
|
||||||
|
|
||||||
|
const mac = loadPathResolvers('darwin', true).iconForTheme(theme);
|
||||||
|
assert.strictEqual(path.extname(mac), '.png');
|
||||||
|
// 打包脚本只复制 32 与 256 两个尺寸,取用的那个必须真实存在
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(iconRoot, theme, path.basename(mac))),
|
||||||
|
`缺少 macOS 窗口图标 ${theme}/${path.basename(mac)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未知主题回落到 dark,不能拼出不存在的路径
|
||||||
|
assert.strictEqual(
|
||||||
|
path.basename(loadPathResolvers('darwin', true).iconForTheme('nope')),
|
||||||
|
'icon-256.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('icns 图标容器结构合法且尺寸齐全', () => {
|
||||||
|
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||||||
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const buf = fs.readFileSync(path.join(iconRoot, `book-ai-${theme}.icns`));
|
||||||
|
assert.strictEqual(buf.toString('latin1', 0, 4), 'icns', `${theme} 魔数错误`);
|
||||||
|
// 长度字段写错时 Finder 会静默显示默认图标,必须逐字节核对
|
||||||
|
assert.strictEqual(buf.readUInt32BE(4), buf.length, `${theme} 长度字段与文件不符`);
|
||||||
|
|
||||||
|
const slots = new Map();
|
||||||
|
for (let off = 8; off < buf.length;) {
|
||||||
|
const type = buf.toString('latin1', off, off + 4);
|
||||||
|
const len = buf.readUInt32BE(off + 4);
|
||||||
|
assert.ok(len >= 8 && off + len <= buf.length, `${theme} 槽 ${type} 长度非法`);
|
||||||
|
const payload = buf.subarray(off + 8, off + len);
|
||||||
|
assert.ok(payload.subarray(0, 8).equals(PNG), `${theme} 槽 ${type} 不是 PNG`);
|
||||||
|
slots.set(type, payload.readUInt32BE(16));
|
||||||
|
off += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, size] of [['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024]]) {
|
||||||
|
assert.strictEqual(slots.get(type), size, `${theme} 缺少 ${type}/${size}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图标产物不被 dist 规则忽略,构建脚本引用的文件都在仓库里', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const ignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8');
|
||||||
|
|
||||||
|
// 不加前导斜杠时 dist/ 会连 icons/dist/ 一起忽略,
|
||||||
|
// 新克隆的仓库缺少图标,构建直接失败
|
||||||
|
assert.match(ignore, /^\/dist\/$/m, '.gitignore 的 dist 规则必须锚定到仓库根');
|
||||||
|
assert.doesNotMatch(ignore, /^dist\/$/m);
|
||||||
|
|
||||||
|
const portable = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
|
||||||
|
const mac = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
|
||||||
|
const required = [
|
||||||
|
'book-ai-dark.ico', 'book-ai-light.ico',
|
||||||
|
'book-ai-dark.icns',
|
||||||
|
path.join('dark', 'icon-32.png'), path.join('light', 'icon-32.png'),
|
||||||
|
path.join('dark', 'icon-256.png'), path.join('light', 'icon-256.png')
|
||||||
|
];
|
||||||
|
for (const rel of required) {
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(root, 'icons', 'dist', rel)),
|
||||||
|
`构建需要的图标缺失: icons/dist/${rel}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(portable.includes('book-ai-dark.ico'));
|
||||||
|
assert.ok(mac.includes('book-ai-dark.icns'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS 打包脚本保留签名前提并产出 arm64 DMG', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const build = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
|
||||||
|
|
||||||
|
assert.strictEqual(pkg.scripts['build:mac'], 'node build-mac.js');
|
||||||
|
assert.match(build, /const TARGET = `\$\{PRODUCT\}-macos-\$\{ARCH\}`/);
|
||||||
|
assert.match(build, /const ARCH = 'arm64'/);
|
||||||
|
|
||||||
|
// 交叉打包做不出可用产物:hdiutil 与 codesign 都只有 macOS 才有
|
||||||
|
assert.match(build, /process\.platform !== 'darwin'/);
|
||||||
|
for (const tool of ['hdiutil', 'codesign', 'ditto']) assert.ok(build.includes(tool), `缺少 ${tool} 检查`);
|
||||||
|
|
||||||
|
// Electron Framework 带符号链接,用 Node 解压会展开成副本并让签名失效
|
||||||
|
assert.match(build, /run\('ditto', \['-x', '-k', zip, cacheDir\]\)/);
|
||||||
|
|
||||||
|
// 重打包后 asar 哈希对不上,留着该字段会启动即报完整性错误
|
||||||
|
assert.match(build, /ElectronAsarIntegrity/);
|
||||||
|
// 官方 helper plist 没有 CFBundleExecutable,改名后必须补上
|
||||||
|
assert.match(build, /CFBundleExecutable/);
|
||||||
|
// 带版本的 framework 要签 Versions/A;Squirrel 里的 ShipIt 是独立可执行文件
|
||||||
|
assert.match(build, /Versions', 'A'/);
|
||||||
|
assert.match(build, /ShipIt/);
|
||||||
|
// 签名必须由内向外,外层 .app 最后签
|
||||||
|
assert.match(build, /targets\.push\(APP_BUNDLE\)/);
|
||||||
|
assert.match(build, /'--sign', '-'/);
|
||||||
|
assert.match(build, /'--verify', '--deep', '--strict'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux 打包直接从官方 zip 转写 tar 并保住可执行位', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const linux = require(path.join(root, 'build-linux.js'));
|
||||||
|
|
||||||
|
assert.strictEqual(pkg.scripts['build:linux'], 'node build-linux.js');
|
||||||
|
|
||||||
|
// 主程序、沙箱与共享库丢了执行位就起不来;普通资源不该被误判成可执行
|
||||||
|
for (const name of ['PeopleLib', 'chrome-sandbox', 'chrome_crashpad_handler', 'libffmpeg.so']) {
|
||||||
|
assert.ok(linux.isExecutableName(name), `${name} 应带可执行位`);
|
||||||
|
}
|
||||||
|
for (const name of ['resources/app/main.js', 'locales/zh-CN.pak', 'icudtl.dat']) {
|
||||||
|
assert.ok(!linux.isExecutableName(name), `${name} 不该带可执行位`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jszip 给出的权限位优先,缺失时才按文件名兜底
|
||||||
|
assert.strictEqual(linux.modeOf({ unixPermissions: 0o644 }, 'chrome-sandbox'), 0o644);
|
||||||
|
assert.strictEqual(linux.modeOf({}, 'chrome-sandbox'), 0o755);
|
||||||
|
assert.strictEqual(linux.modeOf({ unixPermissions: null }, 'resources/app/main.js'), 0o644);
|
||||||
|
|
||||||
|
assert.strictEqual(linux.parseArch(['--arch', 'arm64']), 'arm64');
|
||||||
|
assert.throws(() => linux.parseArch(['--arch', 'mips']), /不支持的架构/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux tar 条目对超长路径用 PAX 头,权限位可被标准解析读回', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const linux = require(path.join(root, 'build-linux.js'));
|
||||||
|
const { readTarEntries } = require(path.join(root, 'build-release.js'));
|
||||||
|
|
||||||
|
// ustar 的 prefix 只能在斜杠处切分,undici 深层路径切不出合法组合
|
||||||
|
const long = `PeopleLib-linux-x64/resources/app/node_modules/undici/lib/web/${'d'.repeat(60)}/x.js`;
|
||||||
|
assert.ok(Buffer.byteLength(long) > 100);
|
||||||
|
|
||||||
|
const buffer = Buffer.concat([
|
||||||
|
linux.tarEntry('PeopleLib-linux-x64/PeopleLib', 0o755, Buffer.from('bin')),
|
||||||
|
linux.tarEntry(long, 0o644, Buffer.from('src')),
|
||||||
|
Buffer.alloc(1024)
|
||||||
|
]);
|
||||||
|
const entries = readTarEntries(buffer);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(entries.map((e) => e.name), ['PeopleLib-linux-x64/PeopleLib', long]);
|
||||||
|
assert.strictEqual(entries[0].mode & 0o111, 0o111);
|
||||||
|
assert.strictEqual(entries[1].mode & 0o111, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('发布打包排除便携版 data 目录并按校验和回验', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const release = fs.readFileSync(path.join(root, 'build-release.js'), 'utf8');
|
||||||
|
const { TARGETS } = require(path.join(root, 'build-release.js'));
|
||||||
|
|
||||||
|
assert.strictEqual(pkg.scripts.release, 'node build-release.js');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
Object.keys(TARGETS).sort(),
|
||||||
|
['linux-arm64', 'linux-x64', 'macos-arm64', 'windows-x64']
|
||||||
|
);
|
||||||
|
// 发布件名字带版本号,Release 页面上不同版本的资产才不会互相覆盖
|
||||||
|
for (const [key, target] of Object.entries(TARGETS)) {
|
||||||
|
assert.ok(target.asset.includes(pkg.version), `${key} 发布件名缺少版本号`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 便携版书库就在构建目录同级 data/,漏掉这条会把用户数据打进公开发布件
|
||||||
|
assert.match(release, /!f\.rel\.startsWith\('data\/'\)/);
|
||||||
|
assert.match(release, /forbidEntries\(names, \[\/\^data\\\/\/, \/\(\^\|\\\/\)_test\\\/\/\]\)/);
|
||||||
|
// 跨平台产物汇总时逐个比对哈希与版本,防止挂上损坏或版本错配的资产
|
||||||
|
assert.match(release, /if \(digest !== file\.sha256\) throw new Error/);
|
||||||
|
assert.match(release, /manifest\.version !== VERSION/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CI 工作流覆盖三平台并在标签上核对版本后发布', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const workflow = fs.readFileSync(path.join(root, '.github', 'workflows', 'build.yml'), 'utf8');
|
||||||
|
|
||||||
|
for (const target of ['windows-2025', 'macos-15', 'ubuntu-24.04', 'ubuntu-24.04-arm']) {
|
||||||
|
assert.ok(workflow.includes(target), `构建矩阵缺少 ${target}`);
|
||||||
|
}
|
||||||
|
// 集成测试要开真实窗口,无头 runner 上没有 xvfb 会直接崩
|
||||||
|
assert.match(workflow, /xvfb-run -a npx electron/);
|
||||||
|
for (const suite of ['startup', 'download', 'cover', 'annotation', 'reader-features', 'library-notes', 'ai-scope']) {
|
||||||
|
assert.ok(workflow.includes(suite), `CI 缺少集成套件 ${suite}`);
|
||||||
|
}
|
||||||
|
for (const suite of fs.readdirSync(path.join(root, 'src', '_test', 'electron'))) {
|
||||||
|
if (!suite.endsWith('.integration.js')) continue;
|
||||||
|
assert.ok(
|
||||||
|
workflow.includes(suite.replace('.integration.js', '')),
|
||||||
|
`CI 漏跑集成套件 ${suite}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仓库 .npmrc 指向 npmmirror,GitHub runner 在境外必须切回官方源
|
||||||
|
assert.match(fs.readFileSync(path.join(root, '.npmrc'), 'utf8'), /registry\.npmmirror\.com/);
|
||||||
|
assert.match(workflow, /npm_config_registry: https:\/\/registry\.npmjs\.org\//);
|
||||||
|
assert.match(workflow, /ELECTRON_MIRROR: https:\/\/github\.com\/electron\/electron\/releases\/download\//);
|
||||||
|
|
||||||
|
// 打标签才发布,且发布前要求标签名与 package.json 版本一致
|
||||||
|
assert.match(workflow, /if: github\.event_name == 'push' && github\.ref_type == 'tag'/);
|
||||||
|
assert.match(workflow, /标签应为/);
|
||||||
|
assert.ok(workflow.includes(`v'+p.version`));
|
||||||
|
assert.match(workflow, /--verify dist\/release-downloads/);
|
||||||
|
// 先建草稿再转正式,上传中途失败不会留下资产不全的 Release
|
||||||
|
assert.match(workflow, /gh release edit "\$tag" --draft --verify-tag/);
|
||||||
|
assert.match(workflow, /gh release edit "\$tag" --draft=false/);
|
||||||
|
assert.ok(pkg.version && /^\d+\.\d+\.\d+$/.test(pkg.version));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => {
|
||||||
|
assert.match(mainSrc, /await requestReaderPurge\(id\)/);
|
||||||
|
assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);
|
||||||
|
assert.match(mainSrc, /function ensureReaderWritable\(entryId\)/);
|
||||||
|
assert.match(mainSrc, /ipcMain\.on\('reader:purgeReady'/);
|
||||||
|
const forgetAt = mainSrc.indexOf('readerStore.forget(id)');
|
||||||
|
const removeAt = mainSrc.indexOf('library.remove(id, deleteFiles)');
|
||||||
|
assert.ok(forgetAt > 0 && removeAt > forgetAt, '显式阅读资料清理必须在移除书库条目前成功');
|
||||||
|
assert.doesNotMatch(mainSrc, /readerStore\.forget\(id\);\s*\}\s*catch\s*\(e\)\s*\{\s*\/\*[^*]*不该阻断移除/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记文档指纹失配时拒绝回退到其它文件', () => {
|
||||||
|
assert.match(mainSrc, /if \(matched < 0\) throw new Error\('笔记关联的原始文件已变更或不存在'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('启动扫描和旧库导入在首屏配置加载后延迟执行', () => {
|
||||||
|
assert.match(mainSrc, /webContents\.once\('did-finish-load'/);
|
||||||
|
assert.match(mainSrc, /setTimeout\(runStartupMaintenance,\s*1500\)/);
|
||||||
|
const maintenanceAt = mainSrc.indexOf('function runStartupMaintenance()');
|
||||||
|
const legacyAt = mainSrc.indexOf('library.importLegacy(userDataDir)', maintenanceAt);
|
||||||
|
const scanAt = mainSrc.indexOf('library.scan()', maintenanceAt);
|
||||||
|
assert.ok(maintenanceAt > 0 && legacyAt > maintenanceAt && scanAt > legacyAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌', () => {
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('dialog:pickLocal'");
|
||||||
|
const end = mainSrc.indexOf('// --- 阅读器 ---', start);
|
||||||
|
const segment = mainSrc.slice(start, end);
|
||||||
|
assert.ok(start > 0 && end > start);
|
||||||
|
assert.match(segment, /localImport\.discover\(r\.filePaths\)/);
|
||||||
|
assert.match(segment, /senderId:\s*event\.sender\.id/);
|
||||||
|
assert.match(segment, /pending\.senderId\s*!==\s*event\.sender\.id/);
|
||||||
|
assert.match(segment, /pendingLocalImports\.delete\(id\)/);
|
||||||
|
assert.match(segment, /10\s*\*\s*60\s*\*\s*1000/);
|
||||||
|
assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内置阅读器允许 PDF、EPUB、无 DRM Kindle 容器与纯文本并保留外部回退', () => {
|
||||||
|
assert.match(
|
||||||
|
mainSrc,
|
||||||
|
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/
|
||||||
|
);
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
|
||||||
|
assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
|
||||||
|
assert.match(mainSrc, /if \(resolved\.format !== 'pdf'\) throw new Error\('只有 PDF 支持页面批注'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF 使用发送者隔离的分段读取且不再整文件经过 IPC', () => {
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('reader:rangeOpen'/);
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('reader:rangeRead'/);
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('reader:rangeClose'/);
|
||||||
|
assert.match(mainSrc, /isReaderSender\(event\.sender\)/);
|
||||||
|
assert.match(mainSrc, /rangeSessions\.closeSender\(senderId\)/);
|
||||||
|
const start = mainSrc.indexOf("ipcMain.handle('reader:bytes'");
|
||||||
|
const end = mainSrc.indexOf("ipcMain.handle('reader:openExternal'", start);
|
||||||
|
const segment = mainSrc.slice(start, end);
|
||||||
|
assert.match(segment, /format === 'pdf'/);
|
||||||
|
assert.match(segment, /readBoundedFile\(abs, MAX_BUFFERED_READER_BYTES\)/);
|
||||||
|
assert.doesNotMatch(segment, /fs\.readFileSync\(abs\)/);
|
||||||
|
|
||||||
|
const ranges = fs.readFileSync(path.join(__dirname, '..', 'reader', 'range-sessions.js'), 'utf8');
|
||||||
|
assert.match(ranges, /MAX_RANGE_BYTES\s*=\s*4\s*\*\s*1024\s*\*\s*1024/);
|
||||||
|
assert.match(ranges, /session\.senderId\s*!==\s*senderIdOf\(senderId\)/);
|
||||||
|
assert.match(ranges, /session\.handle\.read\(buffer,\s*offset,\s*length - offset,\s*start \+ offset\)/);
|
||||||
|
assert.match(ranges, /PDF 文件在阅读期间发生变化/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 图像与取消请求受阅读器发送者和资源边界保护', () => {
|
||||||
|
assert.match(mainSrc, /function isReaderSender\(webContents\)/);
|
||||||
|
assert.match(mainSrc, /ipcMain\.handle\('reader:captureRect'/);
|
||||||
|
assert.match(mainSrc, /function canonicalVisualContexts\(raw\)/);
|
||||||
|
assert.match(mainSrc, /nativeImage\.createFromBuffer/);
|
||||||
|
assert.match(mainSrc, /decoded\.toJPEG\(85\)/);
|
||||||
|
assert.match(mainSrc, /function aiRunKey\(senderId, runId\)/);
|
||||||
|
assert.match(mainSrc, /aiRunKey\(event\.sender\.id/);
|
||||||
|
assert.match(mainSrc, /aiRunKey\(e\.sender\.id/);
|
||||||
|
assert.match(mainSrc, /wc\.once\('destroyed', abortOnDestroy\)/);
|
||||||
|
assert.match(mainSrc, /只有阅读器可以使用 AI 助手/);
|
||||||
|
assert.match(mainSrc, /function notifyAiChanged\(status\)/);
|
||||||
|
assert.match(mainSrc, /webContents\.send\('ai:changed', status\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function freshMirror() {
|
||||||
|
const p = require.resolve(path.join(__dirname, '..', 'sources', 'mirror.js'));
|
||||||
|
delete require.cache[p];
|
||||||
|
return require(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('tryMirrors: 内容级错误立即返回,不再试其它镜像', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
const tried = [];
|
||||||
|
await assert.rejects(
|
||||||
|
m.tryMirrors('p1', ['a', 'b', 'c'], async (x) => {
|
||||||
|
tried.push(x);
|
||||||
|
throw m.contentError('该 DOI 不存在');
|
||||||
|
}),
|
||||||
|
/不存在/
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(tried, ['a'], '内容级错误不该继续轮询');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryMirrors: 内容级错误不拉黑镜像,下次仍优先使用', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
await assert.rejects(m.tryMirrors('p2', ['a', 'b'], async () => {
|
||||||
|
throw m.contentError('没有这篇');
|
||||||
|
}));
|
||||||
|
const tried = [];
|
||||||
|
await m.tryMirrors('p2', ['a', 'b'], async (x) => { tried.push(x); return 'ok'; });
|
||||||
|
assert.strictEqual(tried[0], 'a', '健康镜像被误拉黑了');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryMirrors: 真实网络故障会依次换镜像', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
const tried = [];
|
||||||
|
const r = await m.tryMirrors('p3', ['a', 'b', 'c'], async (x) => {
|
||||||
|
tried.push(x);
|
||||||
|
if (x !== 'c') throw new Error('网络连接失败,请检查网络或代理设置');
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
assert.strictEqual(r, 'ok');
|
||||||
|
assert.deepStrictEqual(tried, ['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryMirrors: 成功镜像会被记住并优先', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
await m.tryMirrors('p4', ['a', 'b', 'c'], async (x) => {
|
||||||
|
if (x !== 'c') throw new Error('请求超时,站点无响应');
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
const tried = [];
|
||||||
|
await m.tryMirrors('p4', ['a', 'b', 'c'], async (x) => { tried.push(x); return 'ok'; });
|
||||||
|
assert.strictEqual(tried[0], 'c', '上次成功的镜像没有被优先');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryMirrors: 全部失败时抛出最后一个错误', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
await assert.rejects(
|
||||||
|
m.tryMirrors('p5', ['a', 'b'], async () => { throw new Error('请求超时,站点无响应'); }),
|
||||||
|
/超时/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raceMirrors: 返回最快成功的结果', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
const r = await m.raceMirrors('r1', ['slow', 'fast'], async (x) => {
|
||||||
|
if (x === 'slow') { await new Promise((s) => setTimeout(s, 200)); return 'slow'; }
|
||||||
|
return 'fast';
|
||||||
|
});
|
||||||
|
assert.strictEqual(r, 'fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raceMirrors: 胜出后中止其余在途请求', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
let aborted = false;
|
||||||
|
const r = await m.raceMirrors('r2', ['loser', 'winner'], async (x, signal) => {
|
||||||
|
if (x === 'winner') return 'w';
|
||||||
|
return new Promise((_res, rej) => {
|
||||||
|
signal.addEventListener('abort', () => { aborted = true; rej(new Error('请求已取消')); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.strictEqual(r, 'w');
|
||||||
|
await new Promise((s) => setTimeout(s, 20));
|
||||||
|
assert.ok(aborted, '败者没有被中止');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raceMirrors: 跳过已拉黑镜像', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
// 先让 bad 因真实故障进黑名单
|
||||||
|
await m.raceMirrors('r3', ['bad', 'good'], async (x) => {
|
||||||
|
if (x === 'bad') throw new Error('网络连接失败,请检查网络或代理设置');
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
const tried = [];
|
||||||
|
await m.raceMirrors('r3', ['bad', 'good'], async (x) => { tried.push(x); return 'ok'; });
|
||||||
|
assert.ok(!tried.includes('bad'), '黑名单在竞速模式下失效了');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raceMirrors: 全部失败时 reject 而不是挂起', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
await assert.rejects(
|
||||||
|
m.raceMirrors('r4', ['a', 'b'], async () => { throw new Error('请求超时,站点无响应'); }),
|
||||||
|
/超时/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raceMirrors: 内容级错误直接结束竞速', async () => {
|
||||||
|
const m = freshMirror();
|
||||||
|
await assert.rejects(
|
||||||
|
m.raceMirrors('r5', ['a', 'b'], async (x) => {
|
||||||
|
if (x === 'a') throw m.contentError('页面结构无法识别');
|
||||||
|
await new Promise((s) => setTimeout(s, 500));
|
||||||
|
return 'late';
|
||||||
|
}),
|
||||||
|
/页面结构无法识别/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function fresh() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-note-assets-'));
|
||||||
|
delete require.cache[require.resolve('../reader/note-assets')];
|
||||||
|
const assets = require('../reader/note-assets');
|
||||||
|
assets.init(root);
|
||||||
|
return { root, assets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdf(file, suffix = '') {
|
||||||
|
fs.writeFileSync(file, `%PDF-1.4\n${suffix}\n%%EOF\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('PDF 底版选择令牌绑定渲染进程且只能由保存流程解析', () => {
|
||||||
|
const { root, assets } = fresh();
|
||||||
|
const file = path.join(root, 'paper.pdf');
|
||||||
|
pdf(file, 'page one');
|
||||||
|
const staged = assets.stagePdf(file, 101);
|
||||||
|
assert.match(staged.token, /^[0-9a-f-]{36}$/);
|
||||||
|
assert.strictEqual(staged.name, 'paper.pdf');
|
||||||
|
assert.throws(() => assets.readDraft(staged.token, 202), /选择已失效/);
|
||||||
|
assert.match(assets.readDraft(staged.token, 101).subarray(0, 5).toString(), /^%PDF-/);
|
||||||
|
|
||||||
|
const canvasContent = {
|
||||||
|
version: 1,
|
||||||
|
pages: [{
|
||||||
|
id: 'pg_one',
|
||||||
|
width: 612,
|
||||||
|
height: 792,
|
||||||
|
background: { type: 'pdf', page: 1, draftToken: staged.token },
|
||||||
|
objects: []
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const resolved = assets.resolveDrafts(canvasContent, 101);
|
||||||
|
assert.match(resolved.content.pages[0].background.assetId, /^pdf_[a-f0-9]{64}$/);
|
||||||
|
assert.ok(!Object.prototype.hasOwnProperty.call(
|
||||||
|
resolved.content.pages[0].background,
|
||||||
|
'draftToken'
|
||||||
|
));
|
||||||
|
assert.deepStrictEqual(resolved.tokens, [staged.token]);
|
||||||
|
assets.commitTokens(resolved.tokens);
|
||||||
|
assert.throws(() => assets.readDraft(staged.token, 101), /选择已失效/);
|
||||||
|
assert.match(assets.readAsset(resolved.content.pages[0].background.assetId).toString(), /page one/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('相同 PDF 复用内容资源并按引用集合清理孤儿', () => {
|
||||||
|
const { root, assets } = fresh();
|
||||||
|
const first = path.join(root, 'first.pdf');
|
||||||
|
const copy = path.join(root, 'copy.pdf');
|
||||||
|
const other = path.join(root, 'other.pdf');
|
||||||
|
pdf(first, 'same bytes');
|
||||||
|
fs.copyFileSync(first, copy);
|
||||||
|
pdf(other, 'different');
|
||||||
|
const a = assets.stagePdf(first, 1);
|
||||||
|
const b = assets.stagePdf(copy, 1);
|
||||||
|
const c = assets.stagePdf(other, 1);
|
||||||
|
const resolve = (token) => assets.resolveDrafts({
|
||||||
|
version: 1,
|
||||||
|
pages: [{
|
||||||
|
id: 'pg_one',
|
||||||
|
width: 612,
|
||||||
|
height: 792,
|
||||||
|
background: { type: 'pdf', page: 1, draftToken: token },
|
||||||
|
objects: []
|
||||||
|
}]
|
||||||
|
}, 1);
|
||||||
|
const ar = resolve(a.token);
|
||||||
|
const br = resolve(b.token);
|
||||||
|
const cr = resolve(c.token);
|
||||||
|
assert.strictEqual(
|
||||||
|
ar.content.pages[0].background.assetId,
|
||||||
|
br.content.pages[0].background.assetId
|
||||||
|
);
|
||||||
|
assert.notStrictEqual(
|
||||||
|
ar.content.pages[0].background.assetId,
|
||||||
|
cr.content.pages[0].background.assetId
|
||||||
|
);
|
||||||
|
assets.commitTokens([...ar.tokens, ...br.tokens, ...cr.tokens]);
|
||||||
|
assert.strictEqual(assets.cleanup([ar.content.pages[0].background.assetId]), 1);
|
||||||
|
assert.doesNotThrow(() => assets.readAsset(ar.content.pages[0].background.assetId));
|
||||||
|
assert.throws(() => assets.readAsset(cr.content.pages[0].background.assetId));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF 底版拒绝伪造文件、非法资源 ID 和不存在资源', () => {
|
||||||
|
const { root, assets } = fresh();
|
||||||
|
const fake = path.join(root, 'fake.pdf');
|
||||||
|
fs.writeFileSync(fake, 'not a pdf');
|
||||||
|
assert.throws(() => assets.stagePdf(fake, 1), /不是有效 PDF/);
|
||||||
|
assert.throws(() => assets.readAsset('../escape'), /资源标识无效/);
|
||||||
|
assert.throws(() => assets.readAsset(`pdf_${'f'.repeat(64)}`));
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const adapterFile = path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs');
|
||||||
|
const src = fs.readFileSync(adapterFile, 'utf8');
|
||||||
|
|
||||||
|
// pdf-adapter.mjs 直接 import vendor 的 pdf.min.mjs,在 Node 里加载会因缺 DOMMatrix 而抛错,
|
||||||
|
// 所以只把不依赖 DOM 的钳制段落切出来求值,断言的仍是生产源码本身。
|
||||||
|
function loadPureBlock() {
|
||||||
|
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||||
|
const end = src.indexOf('const CSS = `');
|
||||||
|
assert.ok(start >= 0 && end > start, '找不到画布上限段落,钳制代码可能被移动或删除');
|
||||||
|
const block = src.slice(start, end).replace(/^export /gm, '');
|
||||||
|
assert.doesNotMatch(
|
||||||
|
block,
|
||||||
|
/\b(window|document|navigator)\b/,
|
||||||
|
'钳制逻辑必须是纯函数,一旦依赖 DOM 就无法在单测里做真实数值断言'
|
||||||
|
);
|
||||||
|
return new Function(`${block}\nreturn { clampCanvasSize, clampRenderQuality };`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clampCanvasSize, clampRenderQuality } = loadPureBlock();
|
||||||
|
|
||||||
|
const MAX_SIDE = 16384;
|
||||||
|
const MAX_AREA = 268435456;
|
||||||
|
|
||||||
|
function assertWithinLimits(out, label) {
|
||||||
|
assert.ok(
|
||||||
|
out.width <= MAX_SIDE && out.height <= MAX_SIDE,
|
||||||
|
`${label} 单边 ${out.width}x${out.height} 超过 ${MAX_SIDE},浏览器会静默给出不可用画布,整页空白且不报错`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
out.width * out.height <= MAX_AREA,
|
||||||
|
`${label} 面积 ${out.width * out.height} 超过 ${MAX_AREA},同样会静默失败`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
out.width >= 1 && out.height >= 1,
|
||||||
|
`${label} 钳制后出现 ${out.width}x${out.height},0 尺寸画布会让 getContext 之后的绘制全部丢弃`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('A4 常规缩放与超采样不触发钳制', () => {
|
||||||
|
// 612x792pt 的 A4 @ scale1.2,倍率 2:1468x1900,远在上限内
|
||||||
|
const out = clampCanvasSize(612 * 1.2, 792 * 1.2, 2);
|
||||||
|
assert.strictEqual(out.clamped, false, '常规页被误钳会白白牺牲清晰度');
|
||||||
|
assert.strictEqual(out.quality, 2, '未触发上限时必须原样保留名义倍率');
|
||||||
|
assert.strictEqual(out.width, 1468, `期望 1468 实际 ${out.width}`);
|
||||||
|
assert.strictEqual(out.height, 1900, `期望 1900 实际 ${out.height}`);
|
||||||
|
assertWithinLimits(out, 'A4@1.2x2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A4 最高缩放叠加 dpr3 仍不触发钳制', () => {
|
||||||
|
// 实测 8929x12628(113M)是可用的,钳制不该把这一档也压下去
|
||||||
|
const out = clampCanvasSize(612 * 5, 792 * 5, 3);
|
||||||
|
assert.strictEqual(out.clamped, false, '这一档实测可用,钳制过度会让高缩放反而更糊');
|
||||||
|
assert.strictEqual(out.width, 9180, `期望 9180 实际 ${out.width}`);
|
||||||
|
assert.strictEqual(out.height, 11880, `期望 11880 实际 ${out.height}`);
|
||||||
|
assertWithinLimits(out, 'A4@5x3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2000pt 大幅面 @ scale5 dpr2 被钳回上限内', () => {
|
||||||
|
// 期望 backing 20000x14000(280M),实测不可用
|
||||||
|
const out = clampCanvasSize(2000 * 5, 1400 * 5, 2);
|
||||||
|
assert.strictEqual(out.clamped, true, '280M 画布必须被识别为超限,否则用户只看到空白页且没有任何提示');
|
||||||
|
assertWithinLimits(out, '2000pt@5x2');
|
||||||
|
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限以保留最大清晰度,实际 ${out.width}`);
|
||||||
|
assert.ok(out.quality < 2, `生效倍率 ${out.quality} 应低于名义倍率 2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4000pt 图纸 @ scale3 dpr2 被钳回上限内', () => {
|
||||||
|
// 期望 backing 24000x18000(432M),实测不可用
|
||||||
|
const out = clampCanvasSize(4000 * 3, 3000 * 3, 2);
|
||||||
|
assert.strictEqual(out.clamped, true, '432M 画布必须被识别为超限');
|
||||||
|
assertWithinLimits(out, '4000pt@3x2');
|
||||||
|
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限,实际 ${out.width}`);
|
||||||
|
assert.strictEqual(out.height, 12288, `期望 12288 实际 ${out.height}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('钳制后的实际比例如实报告,供 render 的 transform 使用', () => {
|
||||||
|
const cssWidth = 4000 * 3;
|
||||||
|
const cssHeight = 3000 * 3;
|
||||||
|
const out = clampCanvasSize(cssWidth, cssHeight, 2);
|
||||||
|
// transform 用名义倍率而不是取整后的实际比例,画面会错位或只画出一角
|
||||||
|
assert.strictEqual(out.scaleX, out.width / cssWidth, 'scaleX 必须等于 backing 宽除以 CSS 宽');
|
||||||
|
assert.strictEqual(out.scaleY, out.height / cssHeight, 'scaleY 必须等于 backing 高除以 CSS 高');
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(out.scaleX - out.scaleY) < 1e-6,
|
||||||
|
`等比钳制下 x/y 比例应基本一致,实际 ${out.scaleX} / ${out.scaleY}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('短边的取整损失也要反映到比例上,不能直接沿用生效倍率', () => {
|
||||||
|
// 长边正好压到上限时它的比例恰等于生效倍率,只有短边能暴露 floor 带来的偏差
|
||||||
|
const out = clampCanvasSize(16000, 17000, 1);
|
||||||
|
assert.strictEqual(out.scaleX, out.width / 16000, 'scaleX 必须按 floor 之后的实际宽算');
|
||||||
|
assert.notStrictEqual(
|
||||||
|
out.scaleX,
|
||||||
|
out.quality,
|
||||||
|
`短边 ${out.width}px 的实际比例应当略小于生效倍率 ${out.quality},直接沿用会让内容画出画布边界`
|
||||||
|
);
|
||||||
|
assert.strictEqual(out.scaleY, out.height / 17000, 'scaleY 必须按 floor 之后的实际高算');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取整误差不会让某一边掉到 0 像素', () => {
|
||||||
|
const out = clampCanvasSize(100000, 1, 1);
|
||||||
|
assertWithinLimits(out, '极端长条页');
|
||||||
|
assert.strictEqual(out.height, 1, `期望至少 1px,实际 ${out.height}`);
|
||||||
|
assert.strictEqual(out.scaleY, 1, 'scaleY 必须按补足到 1px 后的实际比例报告,否则内容会被压到画布外');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('长边超限而面积未超限的页也被钳', () => {
|
||||||
|
// 20000x2000 = 40M,面积远未超限,只有单边超限
|
||||||
|
const out = clampCanvasSize(20000, 2000, 1);
|
||||||
|
assert.strictEqual(out.clamped, true, '只超单边同样不可用,不能只看面积');
|
||||||
|
assertWithinLimits(out, '超长单边');
|
||||||
|
assert.strictEqual(out.width, MAX_SIDE, `期望 ${MAX_SIDE} 实际 ${out.width}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('接近正方形的超大页同时压住面积与单边', () => {
|
||||||
|
// 16000x17000 = 272M,面积超限且短边已经贴着上限,是最容易只压一头的形状
|
||||||
|
const out = clampCanvasSize(16000, 17000, 1);
|
||||||
|
assert.strictEqual(out.clamped, true, '272M 面积必须被识别为超限');
|
||||||
|
assertWithinLimits(out, '16000x17000');
|
||||||
|
assert.strictEqual(out.height, MAX_SIDE, `长边应压到上限,实际 ${out.height}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('面积约束是真在起作用,而不是被单边约束顺带盖住', () => {
|
||||||
|
// 当前常量下 MAX_AREA 恰好等于 MAX_SIDE 的平方,压住长边就顺带压住了面积,
|
||||||
|
// 面积检查的价值只有在上限常量按别的浏览器口径调整时才显现。
|
||||||
|
// 这里把单边上限换成 65535 重新求值,确认删掉面积项会立刻放出 600M 的画布。
|
||||||
|
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||||
|
const end = src.indexOf('const CSS = `');
|
||||||
|
const block = src.slice(start, end)
|
||||||
|
.replace(/^export /gm, '')
|
||||||
|
.replace('const MAX_CANVAS_SIDE = 16384;', 'const MAX_CANVAS_SIDE = 65535;');
|
||||||
|
const wide = new Function(`${block}\nreturn clampCanvasSize;`)();
|
||||||
|
const out = wide(30000, 20000, 1);
|
||||||
|
assert.ok(
|
||||||
|
out.width * out.height <= MAX_AREA,
|
||||||
|
`面积 ${out.width * out.height} 超过 ${MAX_AREA}:面积约束没有独立生效,只靠单边约束挡不住扁平的大幅面页`
|
||||||
|
);
|
||||||
|
assert.strictEqual(out.clamped, true, '面积超限也必须如实上报,否则外壳无法解释清晰度为何被降');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大范围尺寸与倍率组合下上限恒成立', () => {
|
||||||
|
const sides = [1, 200, 612, 792, 1190, 2384, 5000, 10000, 20000, 40000];
|
||||||
|
const qualities = [1, 1.25, 1.5, 2, 2.5, 3, 4];
|
||||||
|
const scales = [0.25, 1, 1.2, 2, 3, 5];
|
||||||
|
for (const w of sides) {
|
||||||
|
for (const h of sides) {
|
||||||
|
for (const q of qualities) {
|
||||||
|
for (const s of scales) {
|
||||||
|
const out = clampCanvasSize(w * s, h * s, q);
|
||||||
|
const label = `${w}x${h} @scale${s} @${q}x`;
|
||||||
|
assertWithinLimits(out, label);
|
||||||
|
assert.ok(
|
||||||
|
out.quality <= q + 1e-9,
|
||||||
|
`${label}:生效倍率 ${out.quality} 不该超过名义倍率 ${q}`
|
||||||
|
);
|
||||||
|
assert.strictEqual(out.scaleX, out.width / (w * s), `${label}:scaleX 与实际 backing 宽不符`);
|
||||||
|
assert.strictEqual(out.scaleY, out.height / (h * s), `${label}:scaleY 与实际 backing 高不符`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法尺寸与倍率退回安全值而不是抛错或产出 NaN', () => {
|
||||||
|
for (const bad of [undefined, null, NaN, 0, -5, 'abc', Infinity]) {
|
||||||
|
const out = clampCanvasSize(bad, bad, bad);
|
||||||
|
assert.ok(
|
||||||
|
Number.isInteger(out.width) && Number.isInteger(out.height),
|
||||||
|
`尺寸 ${String(bad)} 产出了非整数 ${out.width}x${out.height},canvas.width 赋 NaN 会静默变 0`
|
||||||
|
);
|
||||||
|
assertWithinLimits(out, `非法输入 ${String(bad)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderQuality 只接受 1 到 4,非法值回退到 1', () => {
|
||||||
|
assert.strictEqual(clampRenderQuality(1), 1);
|
||||||
|
assert.strictEqual(clampRenderQuality(2), 2);
|
||||||
|
assert.strictEqual(clampRenderQuality(4), 4);
|
||||||
|
assert.strictEqual(clampRenderQuality(1.5), 1.5, '允许非整数档位,1.5x 实测已能显著降低误差');
|
||||||
|
assert.strictEqual(clampRenderQuality(8), 4, '超过 4 倍换不来可感知的清晰度,只会成倍吃显存');
|
||||||
|
assert.strictEqual(clampRenderQuality(0.5), 1, '低于 1 会比现状更糊');
|
||||||
|
for (const bad of [undefined, null, NaN, 'abc', {}, Infinity, -Infinity]) {
|
||||||
|
assert.strictEqual(
|
||||||
|
clampRenderQuality(bad),
|
||||||
|
1,
|
||||||
|
`非法值 ${String(bad)} 必须回退到 1,回退到 NaN 会让整块 backing 计算失效`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死画布上限常量与两条约束', () => {
|
||||||
|
assert.match(src, /const MAX_CANVAS_SIDE = 16384;/, 'Chromium 单边上限,改动前必须先实测');
|
||||||
|
assert.match(src, /const MAX_CANVAS_AREA = 268435456;/, 'Chromium 总面积上限');
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/Math\.min\(want, bySide, byArea\)/,
|
||||||
|
'单边与面积两条约束都要参与,只留一条在上限常量变动后就会漏放超限画布'
|
||||||
|
);
|
||||||
|
assert.match(src, /MAX_CANVAS_SIDE \/ Math\.max\(cssWidth, cssHeight\)/, '单边约束按长边算');
|
||||||
|
assert.match(src, /Math\.sqrt\(MAX_CANVAS_AREA \/ \(cssWidth \* cssHeight\)\)/, '面积约束按开方算');
|
||||||
|
assert.match(src, /Math\.max\(1, Math\.floor\(cssWidth \* applied\)\)/, '钳制后至少保留 1px');
|
||||||
|
assert.match(src, /Math\.max\(1, Math\.floor\(cssHeight \* applied\)\)/, '钳制后至少保留 1px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死超采样倍率的取值与生效方式', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/Math\.max\(1, Math\.min\(4, n\)\)/,
|
||||||
|
'renderQuality 必须被 clamp 到 1..4'
|
||||||
|
);
|
||||||
|
// 一次锁死整条链路:dpr 取大而不是叠乘、期望倍率必须过钳制、画布尺寸只能来自钳制结果
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const dpr = window\.devicePixelRatio \|\| 1;\s*\n\s*const wanted = Math\.max\(dpr, clampRenderQuality\(renderQuality\)\);\s*\n\s*const fit = clampCanvasSize\(vp\.width, vp\.height, wanted\);\s*\n\s*p\.canvas\.width = fit\.width;\s*\n\s*p\.canvas\.height = fit\.height;/,
|
||||||
|
'有效倍率必须是 max(dpr, renderQuality) 且画布尺寸只能取钳制结果,绕过任一步都会重新引入静默空白页'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
[...src.matchAll(/Math\.max\(dpr, clampRenderQuality\(renderQuality\)\)/g)].length,
|
||||||
|
2,
|
||||||
|
'renderPage 与 renderStats 必须用同一个有效倍率公式,否则界面报告的清晰度和实际渲染的不一致'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/transform: identity \? null : \[fit\.scaleX, 0, 0, fit\.scaleY, 0, 0\]/,
|
||||||
|
'transform 必须用钳制后的实际比例,用名义倍率会画错位或只画出一角'
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
src,
|
||||||
|
/transform: dpr === 1 \? null : \[dpr, 0, 0, dpr, 0, 0\]/,
|
||||||
|
'旧的 dpr 直接当 transform 的写法在钳制生效时会画错'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死倍率变化触发全页重建', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/if \(nextScale !== scale \|\| nextQuality !== renderQuality\) \{\s*\n\s*epoch\+\+;/,
|
||||||
|
'倍率变化不走 epoch++ 重建,同屏会残留旧清晰度的页'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/nextQuality = opts && opts\.renderQuality !== undefined\s*\n?\s*\? clampRenderQuality\(opts\.renderQuality\)\s*\n?\s*: renderQuality/,
|
||||||
|
'缺省 renderQuality 时必须沿用当前值,否则每次渲染都会把用户设置重置成 1'
|
||||||
|
);
|
||||||
|
assert.match(src, /renderStats\(\)/, 'renderStats 是外壳读取当前清晰度状态的唯一正当出口');
|
||||||
|
assert.doesNotMatch(src, /window\.__test/, '不允许为测试往生产代码加全局钩子');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 截图路径同样受画布上限保护', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const fit = clampCanvasSize\(area\.width, area\.height, wantScale\);/,
|
||||||
|
'大幅面页在 renderScale 下限 1 时画布等于页面点尺寸,MediaBox 异常的文件会顶到上限'
|
||||||
|
);
|
||||||
|
assert.match(src, /const renderScale = fit\.quality;/, '截图的 viewport 与 transform 必须用钳制后的倍率');
|
||||||
|
assert.match(src, /canvas\.width = fit\.width;/, '截图画布尺寸直接取钳制结果');
|
||||||
|
// 图像只保留一条 JPEG 编码路径,钳制不该顺手引入格式回退
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
[...src.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]),
|
||||||
|
[],
|
||||||
|
'编码仍应集中在 visual-context.mjs,适配器里不该出现新的编码路径'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
const assert = require('node:assert');
|
||||||
|
const test = require('node:test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const modulePath = require.resolve('../reader/range-sessions');
|
||||||
|
const dirs = [];
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-range-sessions-'));
|
||||||
|
dirs.push(dir);
|
||||||
|
const file = path.join(dir, 'fixture.pdf');
|
||||||
|
const bytes = Buffer.alloc(6 * 1024 * 1024);
|
||||||
|
for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251;
|
||||||
|
fs.writeFileSync(file, bytes);
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
const sessions = require(modulePath);
|
||||||
|
sessions.init((_entryId, fileIndex) => ({
|
||||||
|
abs: file,
|
||||||
|
format: 'pdf',
|
||||||
|
fileIndex: Number.isInteger(fileIndex) ? fileIndex : 0
|
||||||
|
}));
|
||||||
|
return { sessions, file, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
try {
|
||||||
|
const sessions = require(modulePath);
|
||||||
|
await sessions.closeAll();
|
||||||
|
} catch (error) { /* ignore */ }
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (error) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF 分段会话只返回请求范围并可显式关闭', async () => {
|
||||||
|
const { sessions, bytes } = fixture();
|
||||||
|
const opened = await sessions.open(10, 'entry', 0);
|
||||||
|
assert.match(opened.sessionId, /^[a-f0-9-]{36}$/);
|
||||||
|
assert.strictEqual(opened.size, bytes.length);
|
||||||
|
assert.strictEqual(opened.chunkSize, sessions.RANGE_CHUNK_BYTES);
|
||||||
|
const begin = 1024 * 1024 + 137;
|
||||||
|
const end = begin + 256 * 1024;
|
||||||
|
const result = await sessions.read(10, opened.sessionId, begin, end);
|
||||||
|
assert.deepStrictEqual(result, bytes.subarray(begin, end));
|
||||||
|
assert.strictEqual(await sessions.close(10, opened.sessionId), true);
|
||||||
|
assert.strictEqual(await sessions.close(10, opened.sessionId), false);
|
||||||
|
assert.strictEqual(sessions.status().sessions, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF 分段会话绑定发送者并限制范围与并发会话数量', async () => {
|
||||||
|
const { sessions, bytes } = fixture();
|
||||||
|
const first = await sessions.open(20, 'entry', 0);
|
||||||
|
await assert.rejects(() => sessions.read(21, first.sessionId, 0, 1024), /无效或已关闭/);
|
||||||
|
await assert.rejects(() => sessions.read(20, first.sessionId, -1, 1024), /范围无效/);
|
||||||
|
await assert.rejects(
|
||||||
|
() => sessions.read(20, first.sessionId, 0, sessions.MAX_RANGE_BYTES + 1),
|
||||||
|
/不能超过 4 MB/
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => sessions.read(20, first.sessionId, bytes.length - 10, bytes.length + 1),
|
||||||
|
/范围无效/
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids = [first.sessionId];
|
||||||
|
for (let index = 0; index < sessions.MAX_SESSIONS_PER_SENDER; index++) {
|
||||||
|
ids.push((await sessions.open(20, 'entry', 0)).sessionId);
|
||||||
|
}
|
||||||
|
assert.strictEqual(sessions.status().sessions, sessions.MAX_SESSIONS_PER_SENDER);
|
||||||
|
await assert.rejects(() => sessions.read(20, ids[0], 0, 1024), /无效或已关闭/);
|
||||||
|
assert.strictEqual(await sessions.closeSender(20), sessions.MAX_SESSIONS_PER_SENDER);
|
||||||
|
assert.strictEqual(sessions.status().sessions, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF 在阅读期间发生变化时拒绝继续提供旧会话数据', async () => {
|
||||||
|
const { sessions, file } = fixture();
|
||||||
|
const opened = await sessions.open(30, 'entry', 0);
|
||||||
|
fs.appendFileSync(file, Buffer.from([1]));
|
||||||
|
await assert.rejects(
|
||||||
|
() => sessions.read(30, opened.sessionId, 0, 1024),
|
||||||
|
/发生变化/
|
||||||
|
);
|
||||||
|
await sessions.closeSender(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40 GB 文件使用安全整数偏移按需读取而不分配整文件缓冲区', async () => {
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
const sessions = require(modulePath);
|
||||||
|
const size = 40 * 1024 * 1024 * 1024;
|
||||||
|
let closed = false;
|
||||||
|
const stat = { size, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
|
||||||
|
const handle = {
|
||||||
|
stat: async () => stat,
|
||||||
|
read: async (buffer, offset, length, position) => {
|
||||||
|
for (let index = 0; index < length; index++) {
|
||||||
|
buffer[offset + index] = (position + index) % 251;
|
||||||
|
}
|
||||||
|
return { bytesRead: length, buffer };
|
||||||
|
},
|
||||||
|
close: async () => { closed = true; }
|
||||||
|
};
|
||||||
|
sessions.init(
|
||||||
|
() => ({ abs: 'virtual-40gb.pdf', format: 'pdf', fileIndex: 0 }),
|
||||||
|
{ promises: { open: async () => handle } }
|
||||||
|
);
|
||||||
|
const opened = await sessions.open(40, 'huge', 0);
|
||||||
|
assert.strictEqual(opened.size, size);
|
||||||
|
const begin = size - 8192;
|
||||||
|
const result = await sessions.read(40, opened.sessionId, begin, begin + 4096);
|
||||||
|
assert.strictEqual(result.length, 4096);
|
||||||
|
assert.strictEqual(result[0], begin % 251);
|
||||||
|
assert.strictEqual(result[4095], (begin + 4095) % 251);
|
||||||
|
await sessions.closeSender(40);
|
||||||
|
assert.strictEqual(closed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('发送者销毁与会话创建竞态不会遗留文件句柄', async () => {
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
const sessions = require(modulePath);
|
||||||
|
const stat = { size: 4096, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
|
||||||
|
let releaseOpen;
|
||||||
|
let closed = false;
|
||||||
|
sessions.init(
|
||||||
|
() => ({ abs: 'delayed.pdf', format: 'pdf', fileIndex: 0 }),
|
||||||
|
{
|
||||||
|
promises: {
|
||||||
|
open: () => new Promise((resolve) => {
|
||||||
|
releaseOpen = () => resolve({
|
||||||
|
stat: async () => stat,
|
||||||
|
read: async () => ({ bytesRead: 0 }),
|
||||||
|
close: async () => { closed = true; }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const opening = sessions.open(50, 'entry', 0);
|
||||||
|
while (!releaseOpen) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await sessions.closeSender(50);
|
||||||
|
releaseOpen();
|
||||||
|
await assert.rejects(opening, /窗口已关闭/);
|
||||||
|
assert.strictEqual(closed, true);
|
||||||
|
assert.strictEqual(sessions.status().sessions, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('范围读取完成后再次校验文件签名', async () => {
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
const sessions = require(modulePath);
|
||||||
|
let changed = false;
|
||||||
|
let closed = false;
|
||||||
|
const handle = {
|
||||||
|
stat: async () => ({
|
||||||
|
size: 4096,
|
||||||
|
mtimeMs: changed ? 2 : 1,
|
||||||
|
ctimeMs: 1,
|
||||||
|
isFile: () => true
|
||||||
|
}),
|
||||||
|
read: async (buffer, offset, length) => {
|
||||||
|
buffer.fill(1, offset, offset + length);
|
||||||
|
changed = true;
|
||||||
|
return { bytesRead: length, buffer };
|
||||||
|
},
|
||||||
|
close: async () => { closed = true; }
|
||||||
|
};
|
||||||
|
sessions.init(
|
||||||
|
() => ({ abs: 'changing.pdf', format: 'pdf', fileIndex: 0 }),
|
||||||
|
{ promises: { open: async () => handle } }
|
||||||
|
);
|
||||||
|
const opened = await sessions.open(60, 'entry', 0);
|
||||||
|
await assert.rejects(
|
||||||
|
() => sessions.read(60, opened.sessionId, 0, 1024),
|
||||||
|
/发生变化/
|
||||||
|
);
|
||||||
|
while (!closed) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.strictEqual(sessions.status().sessions, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const EventEmitter = require('node:events');
|
||||||
|
const Module = require('node:module');
|
||||||
|
|
||||||
|
test('阅读器全局只创建一个窗口,新书与删除请求路由到标签事件', () => {
|
||||||
|
const instances = [];
|
||||||
|
let webContentsId = 0;
|
||||||
|
|
||||||
|
class FakeWindow extends EventEmitter {
|
||||||
|
constructor(options) {
|
||||||
|
super();
|
||||||
|
this.options = options;
|
||||||
|
this.destroyed = false;
|
||||||
|
this.focused = 0;
|
||||||
|
this.webContents = new EventEmitter();
|
||||||
|
this.webContents.id = ++webContentsId;
|
||||||
|
this.webContents.loading = true;
|
||||||
|
this.webContents.sent = [];
|
||||||
|
this.webContents.setWindowOpenHandler = (handler) => { this.webContents.windowOpenHandler = handler; };
|
||||||
|
this.webContents.isLoadingMainFrame = () => this.webContents.loading;
|
||||||
|
this.webContents.send = (channel, payload) => this.webContents.sent.push([channel, payload]);
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
loadFile(file, options) {
|
||||||
|
this.loaded = { file, options };
|
||||||
|
}
|
||||||
|
isDestroyed() { return this.destroyed; }
|
||||||
|
isMinimized() { return false; }
|
||||||
|
focus() { this.focused += 1; }
|
||||||
|
close() {
|
||||||
|
const event = { prevented: false, preventDefault() { this.prevented = true; } };
|
||||||
|
this.emit('close', event);
|
||||||
|
if (!event.prevented) {
|
||||||
|
this.destroyed = true;
|
||||||
|
this.emit('closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
this.destroyed = true;
|
||||||
|
this.emit('closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
Module._load = function mock(request, parent, isMain) {
|
||||||
|
if (request === 'electron') return { BrowserWindow: FakeWindow };
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
const modulePath = require.resolve('../reader/window.js');
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
let windows;
|
||||||
|
try {
|
||||||
|
windows = require(modulePath);
|
||||||
|
} finally {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLocator = { kind: 'pdf', page: 4 };
|
||||||
|
const secondLocator = { kind: 'epub', chapter: 2, offset: 180 };
|
||||||
|
const first = windows.open('book-a', 'C:\\app', 0, firstLocator);
|
||||||
|
const second = windows.open('book-b', 'C:\\app', 1, secondLocator);
|
||||||
|
assert.strictEqual(first, second);
|
||||||
|
assert.strictEqual(instances.length, 1);
|
||||||
|
assert.deepStrictEqual(first.loaded.options.query, {
|
||||||
|
entryId: 'book-a',
|
||||||
|
fileIndex: '0',
|
||||||
|
locator: JSON.stringify(firstLocator)
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(first.webContents.sent, [], '加载完成前不应丢失事件或过早发送');
|
||||||
|
assert.deepStrictEqual(first.webContents.windowOpenHandler(), { action: 'deny' });
|
||||||
|
const navigation = { prevented: false, preventDefault() { this.prevented = true; } };
|
||||||
|
first.webContents.emit('will-navigate', navigation, 'https://untrusted.example/');
|
||||||
|
assert.strictEqual(navigation.prevented, true);
|
||||||
|
|
||||||
|
first.webContents.loading = false;
|
||||||
|
assert.strictEqual(windows.markReady(first.webContents), true);
|
||||||
|
assert.strictEqual(windows.isReady(first), true);
|
||||||
|
assert.deepStrictEqual(first.webContents.sent[0], [
|
||||||
|
'reader:openEntry',
|
||||||
|
{ entryId: 'book-b', fileIndex: 1, locator: secondLocator }
|
||||||
|
]);
|
||||||
|
|
||||||
|
windows.closeFor('book-a');
|
||||||
|
assert.deepStrictEqual(first.webContents.sent[1], ['reader:closeEntry', 'book-a']);
|
||||||
|
windows.purgeFor('book-b', 'purge-1');
|
||||||
|
assert.deepStrictEqual(first.webContents.sent[2], [
|
||||||
|
'reader:purgeEntry',
|
||||||
|
{ entryId: 'book-b', requestId: 'purge-1' }
|
||||||
|
]);
|
||||||
|
assert.strictEqual(first.destroyed, false, '删除一个条目不应关闭整个阅读器窗口');
|
||||||
|
assert.strictEqual(windows.fromWebContents(first.webContents), 'reader');
|
||||||
|
assert.deepStrictEqual(windows.all(), [first]);
|
||||||
|
|
||||||
|
first.close();
|
||||||
|
assert.strictEqual(first.destroyed, false, '关闭前应等待渲染器排空保存队列');
|
||||||
|
assert.deepStrictEqual(first.webContents.sent[3], ['reader:prepareClose', null]);
|
||||||
|
assert.strictEqual(windows.shutdownReady(first.webContents), true);
|
||||||
|
assert.strictEqual(first.destroyed, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const h = require('./helpers');
|
||||||
|
|
||||||
|
h.installFetchStub();
|
||||||
|
const sources = require('../sources');
|
||||||
|
|
||||||
|
test('注册表:每个源都实现完整接口', () => {
|
||||||
|
const list = sources.listSources();
|
||||||
|
assert.ok(list.length >= 16);
|
||||||
|
assert.strictEqual(new Set(list.map((source) => source.id)).size, list.length, '数据源 ID 不能重复');
|
||||||
|
for (const s of list) {
|
||||||
|
const m = sources.getSource(s.id);
|
||||||
|
for (const fn of ['list', 'search', 'detail', 'download']) {
|
||||||
|
assert.strictEqual(typeof m[fn], 'function', `${s.id}.${fn} 缺失`);
|
||||||
|
}
|
||||||
|
assert.ok(s.name, `${s.id} 缺 name`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册表:开放教材与中英文维基文库已启用', () => {
|
||||||
|
const ids = sources.listSources().map((source) => source.id);
|
||||||
|
for (const id of ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en']) {
|
||||||
|
assert.ok(ids.includes(id), `缺少新数据源: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册表:未知 id 抛错', () => {
|
||||||
|
assert.throws(() => sources.getSource('nope'), /未知数据源/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- PMC ---
|
||||||
|
|
||||||
|
test('pmc: esearch 响应异常时给出可读错误而不是 TypeError', async () => {
|
||||||
|
h.setHandler(h.routes([['esearch.fcgi', { body: { error: 'down' } }]]));
|
||||||
|
await assert.rejects(sources.getSource('pmc').search('x', 1), /无法识别的检索结果/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pmc: postId 不重复拼 PMC 前缀', async () => {
|
||||||
|
const seen = [];
|
||||||
|
h.setHandler(h.routes([
|
||||||
|
['esummary.fcgi', (u) => { seen.push(u); return h.makeResponse({ body: { result: { 123: { uid: '123', title: 'T', authors: [] } } } }); }]
|
||||||
|
]));
|
||||||
|
const d = await sources.getSource('pmc').detail('PMC123');
|
||||||
|
assert.strictEqual(d.postId, '123');
|
||||||
|
assert.strictEqual(d.url, 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC123/');
|
||||||
|
assert.ok(!d.url.includes('PMCPMC'), 'URL 里出现了 PMCPMC');
|
||||||
|
assert.ok(seen[0].includes('id=123'), 'esummary 用了带前缀的 id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pmc: 畸形 id 不会把正则搞崩', async () => {
|
||||||
|
h.setHandler(() => h.makeResponse({ body: '' }));
|
||||||
|
await assert.rejects(sources.getSource('pmc').download('12(3'), /无效的 PMC ID/);
|
||||||
|
await assert.rejects(sources.getSource('pmc').download('.*'), /无效的 PMC ID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pmc: 列表按 uid 归一化 postId', async () => {
|
||||||
|
h.setHandler(h.routes([
|
||||||
|
['esearch.fcgi', { body: { esearchresult: { count: '40', idlist: ['777'] } } }],
|
||||||
|
['esummary.fcgi', { body: { result: { 777: { uid: '777', title: 'A', authors: [{ name: 'X' }], pubdate: '2020 Jan' } } } }]
|
||||||
|
]));
|
||||||
|
const r = await sources.getSource('pmc').search('kw', 1);
|
||||||
|
assert.strictEqual(r.items[0].postId, '777');
|
||||||
|
assert.strictEqual(r.maxPage, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- DOAJ ---
|
||||||
|
|
||||||
|
test('doaj: postId 不被二次编码', async () => {
|
||||||
|
const urls = [];
|
||||||
|
h.setHandler(h.routes([
|
||||||
|
['search/articles', { body: { total: 1, results: [{ id: '10.1234/abc', bibjson: { title: 'T', author: [], link: [] } }] } }],
|
||||||
|
['api/v2/articles/', (u) => { urls.push(u); return h.makeResponse({ body: { bibjson: { title: 'T', author: [], link: [] } } }); }]
|
||||||
|
]));
|
||||||
|
const doaj = sources.getSource('doaj');
|
||||||
|
const r = await doaj.search('kw', 1);
|
||||||
|
assert.strictEqual(r.items[0].postId, '10.1234/abc', 'postId 不该预先编码');
|
||||||
|
await doaj.detail(r.items[0].postId);
|
||||||
|
assert.ok(urls[0].includes('10.1234%2Fabc'), '详情 URL 编码错误: ' + urls[0]);
|
||||||
|
assert.ok(!urls[0].includes('%252F'), '出现二次编码: ' + urls[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doaj: DOAJ 页链接正确编码', async () => {
|
||||||
|
h.setHandler(h.routes([['api/v2/articles/', { body: { bibjson: { link: [] } } }]]));
|
||||||
|
const d = await sources.getSource('doaj').download('10.1234/abc');
|
||||||
|
const page = d.links.find((l) => l.name === 'DOAJ 页');
|
||||||
|
assert.strictEqual(page.url, 'https://doaj.org/article/10.1234%2Fabc');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Sci-Hub ---
|
||||||
|
|
||||||
|
test('scihub: 跳过广告 iframe 找到真正的 PDF', async () => {
|
||||||
|
h.setHandler(() => h.makeResponse({
|
||||||
|
body: '<iframe src="https://ads.example/b.html"></iframe><iframe src="/downloads/2020/x.pdf"></iframe>'
|
||||||
|
}));
|
||||||
|
const d = await sources.getSource('scihub').download('10.1038/nature12373');
|
||||||
|
assert.strictEqual(d.files[0].link, 'https://sci-hub.se/downloads/2020/x.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scihub: DOI 不存在时只请求一个镜像', async () => {
|
||||||
|
const hits = [];
|
||||||
|
h.setHandler((u) => { hits.push(u); return h.makeResponse({ body: 'article not found' }); });
|
||||||
|
await assert.rejects(sources.getSource('scihub').detail('10.1/x'), /不存在/);
|
||||||
|
assert.strictEqual(hits.length, 1, `不该轮询全部镜像,实际请求 ${hits.length} 次`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scihub: 非 DOI 关键词返回空而不抛错', async () => {
|
||||||
|
const r = await sources.getSource('scihub').search('随便搜点什么', 1);
|
||||||
|
assert.deepStrictEqual(r.items, []);
|
||||||
|
assert.ok(r.note);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- LibGen ---
|
||||||
|
|
||||||
|
test('libgen: maxPage 只看分页控件,忽略页脚干扰链接', async () => {
|
||||||
|
const card = '<div class="resItemBox" data-book_id="1"><h3 itemprop="name"><a>Book One</a></h3></div>';
|
||||||
|
const footer = '<div class="footer"><a href="/x?page=999">junk</a></div>';
|
||||||
|
const pager = '<div class="paginator"><a href="?page=2">2</a><a href="?page=3">3</a></div>';
|
||||||
|
h.setHandler(() => h.makeResponse({ body: card + footer + pager }));
|
||||||
|
const r = await sources.getSource('libgen').search('godel escher', 1);
|
||||||
|
assert.strictEqual(r.items.length, 1);
|
||||||
|
assert.strictEqual(r.maxPage, 3, '页脚的 page=999 被误算进来了');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('libgen: 无分页控件时不虚报页数', async () => {
|
||||||
|
const card = '<div class="resItemBox" data-book_id="9"><h3 itemprop="name"><a>Solo</a></h3></div>';
|
||||||
|
h.setHandler(() => h.makeResponse({ body: card + '<a href="/y?page=42">junk</a>' }));
|
||||||
|
const r = await sources.getSource('libgen').search('solo book', 1);
|
||||||
|
assert.strictEqual(r.maxPage, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('libgen: JSON-LD image 为对象时详情不崩溃', async () => {
|
||||||
|
const ld = JSON.stringify({ '@type': 'Book', name: 'B', image: { '@type': 'ImageObject', url: '/c.jpg' } });
|
||||||
|
h.setHandler(() => h.makeResponse({
|
||||||
|
body: `<script type="application/ld+json">${ld}</script><h1 itemprop="name">B</h1>`
|
||||||
|
}));
|
||||||
|
const d = await sources.getSource('libgen').detail('web:5');
|
||||||
|
assert.strictEqual(d.title, 'B');
|
||||||
|
assert.ok(/\/c\.jpg$/.test(d.cover), 'cover 解析失败: ' + d.cover);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('libgen: 关键词过短直接返回提示', async () => {
|
||||||
|
const r = await sources.getSource('libgen').search('ab', 1);
|
||||||
|
assert.deepStrictEqual(r.items, []);
|
||||||
|
assert.ok(r.note);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Standard Ebooks ---
|
||||||
|
|
||||||
|
test('standardebooks: author 为字符串时不丢作者', async () => {
|
||||||
|
h.setHandler(h.routes([['feeds/opds/all', {
|
||||||
|
body: {
|
||||||
|
publications: [{
|
||||||
|
metadata: { identifier: 'https://standardebooks.org/ebooks/jane-austen/emma', title: 'Emma', author: 'Jane Austen' },
|
||||||
|
images: []
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
const r = await sources.getSource('standardebooks').search('emma', 1);
|
||||||
|
assert.strictEqual(r.items[0].subtitle, 'Jane Austen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standardebooks: author 混排对象与字符串', async () => {
|
||||||
|
h.setHandler(h.routes([['feeds/opds/all', {
|
||||||
|
body: {
|
||||||
|
publications: [{
|
||||||
|
metadata: { identifier: 'https://standardebooks.org/ebooks/a/b', title: 'T', author: [{ name: 'A' }, 'B'] },
|
||||||
|
images: []
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
const r = await sources.getSource('standardebooks').search('t', 1);
|
||||||
|
assert.strictEqual(r.items[0].subtitle, 'A, B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standardebooks: 非法 slug 被拒绝', async () => {
|
||||||
|
await assert.rejects(sources.getSource('standardebooks').detail('../../etc/passwd'), /无效的/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Open Library ---
|
||||||
|
|
||||||
|
test('openlibrary: 详情解析作者姓名', async () => {
|
||||||
|
h.setHandler(h.routes([
|
||||||
|
[/works\/OL1W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/OL1A' } }], subjects: [] } }],
|
||||||
|
[/authors\/OL1A\.json/, { body: { name: 'Ursula Le Guin' } }]
|
||||||
|
]));
|
||||||
|
const d = await sources.getSource('openlibrary').detail('OL1W');
|
||||||
|
assert.deepStrictEqual(d.authors, ['Ursula Le Guin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openlibrary: 单个作者取不到不影响整体', async () => {
|
||||||
|
h.setHandler(h.routes([
|
||||||
|
[/works\/OL2W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/BAD' } }, { author: { key: '/authors/OK' } }], subjects: [] } }],
|
||||||
|
// 404 不触发重试,避免这条用例白等两轮退避
|
||||||
|
[/authors\/BAD\.json/, { status: 404 }],
|
||||||
|
[/authors\/OK\.json/, { body: { name: 'Good' } }]
|
||||||
|
]));
|
||||||
|
const d = await sources.getSource('openlibrary').detail('OL2W');
|
||||||
|
assert.deepStrictEqual(d.authors, ['Good']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openlibrary: 下载只给 archive.org 真实存在的文件', async () => {
|
||||||
|
h.setHandler(h.routes([
|
||||||
|
['editions.json', { body: { entries: [{ ocaid: 'someitem' }] } }],
|
||||||
|
['archive.org/metadata/', {
|
||||||
|
body: { files: [{ name: 'someitem.pdf', format: 'Text PDF' }, { name: 'thumb.jpg', format: 'JPEG' }] }
|
||||||
|
}]
|
||||||
|
]));
|
||||||
|
const d = await sources.getSource('openlibrary').download('OL3W');
|
||||||
|
assert.strictEqual(d.files.length, 1, '推了不存在的格式: ' + JSON.stringify(d.files));
|
||||||
|
assert.strictEqual(d.files[0].format, 'PDF');
|
||||||
|
assert.ok(d.files[0].link.includes('someitem.pdf'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openlibrary: 借阅制条目被跳过', async () => {
|
||||||
|
h.setHandler(h.routes([
|
||||||
|
['editions.json', { body: { entries: [{ ocaid: 'lend', access_restricted: 'borrow' }] } }]
|
||||||
|
]));
|
||||||
|
const d = await sources.getSource('openlibrary').download('OL4W');
|
||||||
|
assert.deepStrictEqual(d.files, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- bioRxiv ---
|
||||||
|
|
||||||
|
test('biorxiv: 瞬时故障会重试而不是直接失败', async () => {
|
||||||
|
let n = 0;
|
||||||
|
h.setHandler(() => {
|
||||||
|
n++;
|
||||||
|
// 502 与超时走的是同一条 isRetryable 分支,用 502 避免真的等满超时
|
||||||
|
if (n <= 2) return h.makeResponse({ status: 502 });
|
||||||
|
return h.makeResponse({ body: { messages: [{ total: 100 }], collection: [] } });
|
||||||
|
});
|
||||||
|
const r = await sources.getSource('biorxiv').list(1);
|
||||||
|
assert.ok(n >= 3, `没有重试,只请求了 ${n} 次`);
|
||||||
|
assert.ok(r.maxPage >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('biorxiv: 超时被判定为可重试(回归 504|502|503 正则漏判)', () => {
|
||||||
|
const { isRetryable } = require('../sources/http');
|
||||||
|
assert.strictEqual(isRetryable(new Error('请求超时,站点无响应')), true);
|
||||||
|
assert.strictEqual(isRetryable(new Error('网络连接失败,请检查网络或代理设置')), true);
|
||||||
|
const src = require('fs').readFileSync(require.resolve('../sources/biorxiv.js'), 'utf8');
|
||||||
|
assert.ok(!/504\|502\|503/.test(src), '旧的字符串匹配门仍在');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('biorxiv: 不支持搜索时明确报错', async () => {
|
||||||
|
await assert.rejects(sources.getSource('biorxiv').search('x', 1), /不支持搜索/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- MOTW ---
|
||||||
|
|
||||||
|
test('motw: 分页用 offset/limit 且随页码递增', async () => {
|
||||||
|
const urls = [];
|
||||||
|
h.setHandler((u) => {
|
||||||
|
urls.push(u);
|
||||||
|
return h.makeResponse({ body: { _items: [], _meta: { total: 1000, max_results: 48 } } });
|
||||||
|
});
|
||||||
|
const motw = sources.getSource('motw');
|
||||||
|
await motw.list(1);
|
||||||
|
await motw.list(3);
|
||||||
|
assert.ok(urls[0].includes('offset=0&limit=48'), urls[0]);
|
||||||
|
assert.ok(urls[1].includes('offset=96&limit=48'), urls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('motw: 未缓存的详情给出可操作提示', async () => {
|
||||||
|
await assert.rejects(sources.getSource('motw').detail('unknown-id'), /重新进入/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- arXiv ---
|
||||||
|
|
||||||
|
test('arxiv: 解析 atom feed 并取 pdf 链接', async () => {
|
||||||
|
const xml = `<feed><opensearch:totalResults>40</opensearch:totalResults>
|
||||||
|
<entry><id>http://arxiv.org/abs/2201.00978v1</id><title>Paper T</title>
|
||||||
|
<summary>S</summary><published>2022-01-03T00:00:00Z</published>
|
||||||
|
<author><name>A One</name></author>
|
||||||
|
<link title="pdf" href="https://arxiv.org/pdf/2201.00978v1"/>
|
||||||
|
<category term="cs.CV"/></entry></feed>`;
|
||||||
|
h.setHandler(() => h.makeResponse({ body: xml }));
|
||||||
|
const r = await sources.getSource('arxiv').search('transformer', 1);
|
||||||
|
assert.strictEqual(r.items[0].postId, '2201.00978v1');
|
||||||
|
assert.strictEqual(r.maxPage, 2);
|
||||||
|
const d = await sources.getSource('arxiv').download('2201.00978v1');
|
||||||
|
assert.strictEqual(d.files[0].link, 'https://arxiv.org/pdf/2201.00978v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- OpenStax ---
|
||||||
|
|
||||||
|
test('openstax: 只展示 live 教材并支持本地关键词分页', async () => {
|
||||||
|
const openstax = h.freshRequire('sources/openstax.js');
|
||||||
|
const books = Array.from({ length: 21 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slug: `books/book-${i + 1}`,
|
||||||
|
book_state: 'live',
|
||||||
|
title: i === 20 ? 'Advanced Calculus' : `Biology ${i + 1}`,
|
||||||
|
subjects: i === 20 ? ['Math'] : ['Science'],
|
||||||
|
subject_categories: []
|
||||||
|
}));
|
||||||
|
books.push({ id: 99, slug: 'books/draft', book_state: 'draft', title: 'Draft Calculus' });
|
||||||
|
h.setHandler(h.routes([['/apps/cms/api/books', { body: { books } }]]));
|
||||||
|
|
||||||
|
const page2 = await openstax.list(2);
|
||||||
|
assert.strictEqual(page2.items.length, 1);
|
||||||
|
assert.strictEqual(page2.maxPage, 2);
|
||||||
|
const found = await openstax.search('advanced math', 1);
|
||||||
|
assert.deepStrictEqual(found.items.map((item) => item.postId), ['21']);
|
||||||
|
assert.strictEqual(found.items[0].url, 'https://openstax.org/details/books/book-21');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openstax: 详情解析作者、许可和日期', async () => {
|
||||||
|
const openstax = h.freshRequire('sources/openstax.js');
|
||||||
|
h.setHandler(h.routes([['/apps/cms/api/v2/pages/76/', {
|
||||||
|
body: {
|
||||||
|
id: 76,
|
||||||
|
meta: { slug: 'calculus-volume-3', html_url: 'https://openstax.org/details/books/calculus-volume-3' },
|
||||||
|
title: 'Calculus Volume 3',
|
||||||
|
publish_date: '2016-03-30',
|
||||||
|
authors: [{ value: { name: 'Gilbert Strang' } }, { name: 'Second Author' }],
|
||||||
|
book_subjects: { subject_name: 'Math' },
|
||||||
|
book_categories: [{ subject_name: 'Calculus' }],
|
||||||
|
description: '<p>Open <b>calculus</b> textbook.</p>',
|
||||||
|
license_name: 'Creative Commons Attribution-NonCommercial-ShareAlike License',
|
||||||
|
license_version: '4.0',
|
||||||
|
digital_isbn_13: '978-1-947172-16-6'
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
|
||||||
|
const detail = await openstax.detail('76');
|
||||||
|
assert.deepStrictEqual(detail.authors, ['Gilbert Strang', 'Second Author']);
|
||||||
|
assert.strictEqual(detail.date, '2016-03-30');
|
||||||
|
assert.strictEqual(detail.brief, 'Open calculus textbook.');
|
||||||
|
assert.ok(detail.tags.includes('主题:Math'));
|
||||||
|
assert.ok(detail.tags.some((tag) => tag.includes('4.0')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openstax: PDF 去重且非法 id 不发请求', async () => {
|
||||||
|
const openstax = h.freshRequire('sources/openstax.js');
|
||||||
|
h.resetCalls();
|
||||||
|
h.setHandler(h.routes([['/apps/cms/api/v2/pages/76/', {
|
||||||
|
body: {
|
||||||
|
id: 76,
|
||||||
|
meta: { slug: 'calculus-volume-3' },
|
||||||
|
title: 'Calculus: Volume 3',
|
||||||
|
pdf_url: 'https://assets.openstax.org/calculus.pdf',
|
||||||
|
high_resolution_pdf_url: 'https://assets.openstax.org/calculus.pdf',
|
||||||
|
license_url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/'
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
|
||||||
|
const download = await openstax.download('76');
|
||||||
|
assert.strictEqual(download.files.length, 1);
|
||||||
|
assert.strictEqual(download.files[0].name, 'Calculus_ Volume 3.pdf');
|
||||||
|
await assert.rejects(openstax.detail('../76'), /无效的 OpenStax ID/);
|
||||||
|
assert.strictEqual(h.getCalls().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Open Textbook Library ---
|
||||||
|
|
||||||
|
test('opentextbook: 搜索结果解析作者和服务端分页', async () => {
|
||||||
|
const opentextbook = h.freshRequire('sources/opentextbook.js');
|
||||||
|
h.setHandler(h.routes([['textbooks.json?q=calculus&page=2', {
|
||||||
|
body: {
|
||||||
|
data: [{
|
||||||
|
id: 10,
|
||||||
|
title: 'Calculus',
|
||||||
|
copyright_year: 2023,
|
||||||
|
contributors: [
|
||||||
|
{ first_name: 'Gilbert', last_name: 'Strang' },
|
||||||
|
{ corporate: true, title: 'Open Education Team' }
|
||||||
|
],
|
||||||
|
url: 'https://open.umn.edu/opentextbooks/textbooks/calculus'
|
||||||
|
}],
|
||||||
|
links: { total_pages: 10, total_count: 98 }
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
|
||||||
|
const result = await opentextbook.search('calculus', 2);
|
||||||
|
assert.strictEqual(result.page, 2);
|
||||||
|
assert.strictEqual(result.maxPage, 10);
|
||||||
|
assert.strictEqual(result.items[0].subtitle, 'Gilbert Strang, Open Education Team');
|
||||||
|
assert.strictEqual(result.items[0].date, '2023');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opentextbook: 详情展开 data 并保留单书许可', async () => {
|
||||||
|
const opentextbook = h.freshRequire('sources/opentextbook.js');
|
||||||
|
h.setHandler(h.routes([['textbooks/10.json', {
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
id: 10,
|
||||||
|
title: 'Calculus',
|
||||||
|
edition_statement: 'Third Edition',
|
||||||
|
copyright_year: 1991,
|
||||||
|
license: 'Attribution-NonCommercial-ShareAlike',
|
||||||
|
language: 'eng',
|
||||||
|
description: '<p>Free <b>calculus</b> textbook.</p>',
|
||||||
|
contributors: [{ first_name: 'Gilbert', last_name: 'Strang' }],
|
||||||
|
subjects: [{ name: 'Mathematics' }],
|
||||||
|
url: 'https://open.umn.edu/opentextbooks/textbooks/calculus'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
|
||||||
|
const detail = await opentextbook.detail('10');
|
||||||
|
assert.deepStrictEqual(detail.authors, ['Gilbert Strang']);
|
||||||
|
assert.strictEqual(detail.brief, 'Free calculus textbook.');
|
||||||
|
assert.ok(detail.tags.includes('版本:Third Edition'));
|
||||||
|
assert.ok(detail.tags.includes('许可:Attribution-NonCommercial-ShareAlike'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opentextbook: 只有真实文件 URL 才进入下载列表', async () => {
|
||||||
|
const opentextbook = h.freshRequire('sources/opentextbook.js');
|
||||||
|
h.resetCalls();
|
||||||
|
h.setHandler(h.routes([['textbooks/10.json', {
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
id: 10,
|
||||||
|
title: 'Calculus: Third Edition',
|
||||||
|
url: 'https://open.umn.edu/opentextbooks/textbooks/calculus',
|
||||||
|
formats: [
|
||||||
|
{ type: 'PDF', url: 'https://ocw.mit.edu/courses/calculus/open-textbook/' },
|
||||||
|
{ type: 'PDF', url: 'https://cdn.example/calculus.pdf?download=1' },
|
||||||
|
{ type: 'EPUB', url: 'https://cdn.example/calculus.epub' },
|
||||||
|
{ type: 'EPUB', url: 'https://cdn.example/calculus.epub' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
|
||||||
|
const download = await opentextbook.download('10');
|
||||||
|
assert.deepStrictEqual(download.files.map((file) => file.format), ['PDF', 'EPUB']);
|
||||||
|
assert.strictEqual(download.links[1].name, 'PDF 获取页');
|
||||||
|
assert.strictEqual(download.files[0].name, 'Calculus_ Third Edition.pdf');
|
||||||
|
await assert.rejects(opentextbook.download('10/../../x'), /无效的开放教材 ID/);
|
||||||
|
assert.strictEqual(h.getCalls().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Wikisource ---
|
||||||
|
|
||||||
|
test('wikisource: 搜索使用整数偏移并携带可识别 User-Agent', async () => {
|
||||||
|
const wikisource = h.freshRequire('sources/wikisource-zh.js');
|
||||||
|
let request = null;
|
||||||
|
h.setHandler((url, options) => {
|
||||||
|
request = { url, options };
|
||||||
|
return h.makeResponse({
|
||||||
|
body: {
|
||||||
|
query: {
|
||||||
|
searchinfo: { totalhits: 24753 },
|
||||||
|
search: [{ pageid: 6, title: '論語' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await wikisource.search('論語', 2);
|
||||||
|
assert.strictEqual(result.items[0].postId, '6');
|
||||||
|
assert.strictEqual(result.items[0].subtitle, '中文');
|
||||||
|
assert.strictEqual(result.maxPage, 500, 'MediaWiki 搜索最多允许偏移到 10000 条');
|
||||||
|
assert.ok(request.url.includes('sroffset=20'), request.url);
|
||||||
|
assert.match(request.options.headers['User-Agent'], /PeopleLib\/2\.1\.0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wikisource: 浏览按 continuation 令牌翻页', async () => {
|
||||||
|
const wikisource = h.freshRequire('sources/wikisource-en.js');
|
||||||
|
const urls = [];
|
||||||
|
h.setHandler((url) => {
|
||||||
|
urls.push(url);
|
||||||
|
if (url.includes('apcontinue=')) {
|
||||||
|
return h.makeResponse({ body: { query: { allpages: [{ pageid: 2, title: 'Second Book' }] } } });
|
||||||
|
}
|
||||||
|
return h.makeResponse({
|
||||||
|
body: {
|
||||||
|
continue: { apcontinue: 'Second Book', continue: '-||' },
|
||||||
|
query: { allpages: [{ pageid: 1, title: 'First Book' }] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await wikisource.list(1);
|
||||||
|
const second = await wikisource.list(2);
|
||||||
|
assert.strictEqual(first.maxPage, 2);
|
||||||
|
assert.strictEqual(second.items[0].postId, '2');
|
||||||
|
assert.ok(urls[1].includes('apcontinue=Second+Book'), urls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wikisource: 详情和导出链接由服务端标题生成', async () => {
|
||||||
|
const wikisource = h.freshRequire('sources/wikisource-zh.js');
|
||||||
|
h.resetCalls();
|
||||||
|
h.setHandler(h.routes([['pageids=6', {
|
||||||
|
body: {
|
||||||
|
query: {
|
||||||
|
pages: [{
|
||||||
|
pageid: 6,
|
||||||
|
title: '論語/學而第一',
|
||||||
|
extract: '<p>學而時習之。</p>',
|
||||||
|
fullurl: 'https://zh.wikisource.org/wiki/%E8%AB%96%E8%AA%9E',
|
||||||
|
thumbnail: { source: 'https://upload.wikimedia.org/cover.jpg' }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
|
||||||
|
const detail = await wikisource.detail('6');
|
||||||
|
assert.strictEqual(detail.brief, '學而時習之。');
|
||||||
|
assert.strictEqual(detail.cover, 'https://upload.wikimedia.org/cover.jpg');
|
||||||
|
const download = await wikisource.download('6');
|
||||||
|
assert.deepStrictEqual(download.files.map((file) => file.format), ['EPUB', 'PDF']);
|
||||||
|
assert.ok(download.files[0].link.includes('lang=zh'));
|
||||||
|
assert.ok(download.files[0].link.includes('page=%E8%AB%96%E8%AA%9E%2F%E5%AD%B8%E8%80%8C%E7%AC%AC%E4%B8%80'));
|
||||||
|
assert.strictEqual(download.files[0].name, '論語_學而第一.epub');
|
||||||
|
await assert.rejects(wikisource.detail('../6'), /无效的中文维基文库 ID/);
|
||||||
|
assert.strictEqual(h.getCalls().length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Z-Library ---
|
||||||
|
|
||||||
|
test('zlib: postId 缺 hash 时详情仍可用', async () => {
|
||||||
|
const zlib = h.freshRequire('sources/zlib.js');
|
||||||
|
const auth = require('../sources/zlib-auth');
|
||||||
|
const origSession = auth.getSession;
|
||||||
|
const origRead = auth.read;
|
||||||
|
auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' });
|
||||||
|
auth.read = () => ({ email: 'e', password: 'p', userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' });
|
||||||
|
try {
|
||||||
|
const urls = [];
|
||||||
|
h.setHandler((u) => {
|
||||||
|
urls.push(u);
|
||||||
|
return h.makeResponse({ body: { success: 1, book: { title: 'B', author: 'X' } } });
|
||||||
|
});
|
||||||
|
const d = await zlib.detail('123/');
|
||||||
|
assert.strictEqual(d.title, 'B');
|
||||||
|
assert.ok(urls[0].includes('/eapi/book/123'), urls[0]);
|
||||||
|
assert.ok(!urls[0].includes('/eapi/book/123/?'), '缺 hash 时不该留下尾斜杠: ' + urls[0]);
|
||||||
|
} finally {
|
||||||
|
auth.getSession = origSession;
|
||||||
|
auth.read = origRead;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zlib: 完全无效的 id 仍然拒绝', async () => {
|
||||||
|
const zlib = h.freshRequire('sources/zlib.js');
|
||||||
|
await assert.rejects(zlib.detail('not-an-id'), /无效的 Z-Library ID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 实测:会话过期时 /file 返回 400 + {"success":0,"error":"Please login"}。
|
||||||
|
// 若按 HTTP 状态码短路,真实原因会被吞掉,自动重登也不会触发。
|
||||||
|
test('zlib: 4xx+JSON 的会话过期能被识别并自动重新登录', async () => {
|
||||||
|
const zlib = h.freshRequire('sources/zlib.js');
|
||||||
|
const auth = require('../sources/zlib-auth');
|
||||||
|
const orig = { read: auth.read, getSession: auth.getSession, setSession: auth.setSession, clearSession: auth.clearSession };
|
||||||
|
let session = { userId: 'old', userKey: 'stale', mirror: 'https://z-lib.fm' };
|
||||||
|
auth.read = () => ({ email: 'e@x.com', password: 'p', ...session });
|
||||||
|
auth.getSession = () => (session.userKey ? session : null);
|
||||||
|
auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
|
||||||
|
auth.clearSession = () => { session = { userId: '', userKey: '', mirror: '' }; };
|
||||||
|
try {
|
||||||
|
let loggedIn = false;
|
||||||
|
h.setHandler((url) => {
|
||||||
|
if (url.includes('/rpc.php')) {
|
||||||
|
loggedIn = true;
|
||||||
|
return h.makeResponse({
|
||||||
|
headers: {
|
||||||
|
'set-cookie': [
|
||||||
|
'remix_userid=42; Path=/; Secure; HttpOnly',
|
||||||
|
'remix_userkey=fresh; Path=/; Secure; HttpOnly'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
body: { errors: [], response: { redirect: '/' } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('userKey=fresh')) {
|
||||||
|
return h.makeResponse({ body: { success: 1, file: { downloadLink: 'https://cdn/x.pdf', extension: 'pdf' } } });
|
||||||
|
}
|
||||||
|
return h.makeResponse({ status: 400, body: { success: 0, error: 'Please login' } });
|
||||||
|
});
|
||||||
|
const d = await zlib.download('123/abc');
|
||||||
|
assert.ok(loggedIn, '过期会话没有触发重新登录');
|
||||||
|
assert.strictEqual(d.files[0].link, 'https://cdn/x.pdf');
|
||||||
|
} finally {
|
||||||
|
Object.assign(auth, orig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zlib: 凭据错误时报出服务端原因而不是 HTTP 状态码', async () => {
|
||||||
|
const zlib = h.freshRequire('sources/zlib.js');
|
||||||
|
const auth = require('../sources/zlib-auth');
|
||||||
|
const orig = { read: auth.read, getSession: auth.getSession, write: auth.write, clear: auth.clear };
|
||||||
|
// login() 先写盘,doLogin() 再读回来,所以 stub 要如实模拟这个往返
|
||||||
|
let stored = null;
|
||||||
|
auth.read = () => stored;
|
||||||
|
auth.getSession = () => null;
|
||||||
|
auth.write = (c) => { stored = { ...c }; };
|
||||||
|
auth.clear = () => { stored = null; };
|
||||||
|
try {
|
||||||
|
let request = null;
|
||||||
|
h.setHandler((url, options) => {
|
||||||
|
request = { url, body: options.body };
|
||||||
|
return h.makeResponse({
|
||||||
|
body: {
|
||||||
|
errors: [],
|
||||||
|
response: {
|
||||||
|
validationError: true,
|
||||||
|
fields: ['email', 'password'],
|
||||||
|
message: 'Incorrect email or password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const r = await zlib.login('e@x.com', 'wrong');
|
||||||
|
assert.strictEqual(r.ok, false);
|
||||||
|
assert.match(r.error, /Incorrect email or password/, '真实原因被 HTTP 状态码盖掉了');
|
||||||
|
assert.ok(request.url.endsWith('/rpc.php'));
|
||||||
|
assert.match(request.body, /action=login/);
|
||||||
|
assert.match(request.body, /gg_json_mode=1/);
|
||||||
|
} finally {
|
||||||
|
Object.assign(auth, orig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zlib: RPC 登录从安全 Cookie 建立会话', async () => {
|
||||||
|
const zlib = h.freshRequire('sources/zlib.js');
|
||||||
|
const auth = require('../sources/zlib-auth');
|
||||||
|
const orig = {
|
||||||
|
read: auth.read,
|
||||||
|
getSession: auth.getSession,
|
||||||
|
write: auth.write,
|
||||||
|
setSession: auth.setSession,
|
||||||
|
clear: auth.clear
|
||||||
|
};
|
||||||
|
let stored = null;
|
||||||
|
let session = null;
|
||||||
|
auth.read = () => stored;
|
||||||
|
auth.getSession = () => session;
|
||||||
|
auth.write = (c) => { stored = { ...c }; };
|
||||||
|
auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
|
||||||
|
auth.clear = () => { stored = null; session = null; };
|
||||||
|
try {
|
||||||
|
h.setHandler(() => h.makeResponse({
|
||||||
|
headers: {
|
||||||
|
'set-cookie': [
|
||||||
|
'remix_userid=42; Path=/; Secure; HttpOnly',
|
||||||
|
'remix_userkey=key%2Bvalue; Path=/; Secure; HttpOnly'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
body: { errors: [], response: { redirect: '/' } }
|
||||||
|
}));
|
||||||
|
const r = await zlib.login('e@x.com', 'correct');
|
||||||
|
assert.strictEqual(r.ok, true);
|
||||||
|
assert.strictEqual(session.userId, '42');
|
||||||
|
assert.strictEqual(session.userKey, 'key+value');
|
||||||
|
assert.match(session.mirror, /^https:\/\//);
|
||||||
|
} finally {
|
||||||
|
Object.assign(auth, orig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zlib: 可注入同源浏览器登录传输并持久化会话', async () => {
|
||||||
|
const zlib = h.freshRequire('sources/zlib.js');
|
||||||
|
const auth = require('../sources/zlib-auth');
|
||||||
|
const orig = {
|
||||||
|
read: auth.read,
|
||||||
|
getSession: auth.getSession,
|
||||||
|
write: auth.write,
|
||||||
|
setSession: auth.setSession,
|
||||||
|
clear: auth.clear
|
||||||
|
};
|
||||||
|
let stored = null;
|
||||||
|
let session = null;
|
||||||
|
auth.read = () => stored;
|
||||||
|
auth.getSession = () => session;
|
||||||
|
auth.write = (c) => { stored = { ...c }; };
|
||||||
|
auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
|
||||||
|
auth.clear = () => { stored = null; session = null; };
|
||||||
|
zlib.setLoginTransport(async (mirror, email, password) => {
|
||||||
|
assert.match(mirror, /^https:\/\//);
|
||||||
|
assert.strictEqual(email, 'e@x.com');
|
||||||
|
assert.strictEqual(password, 'correct');
|
||||||
|
return { userId: 'browser-user', userKey: 'browser-key' };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await zlib.login('e@x.com', 'correct');
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(session.userId, 'browser-user');
|
||||||
|
assert.strictEqual(session.userKey, 'browser-key');
|
||||||
|
} finally {
|
||||||
|
zlib.setLoginTransport(null);
|
||||||
|
Object.assign(auth, orig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Gutenberg ---
|
||||||
|
|
||||||
|
test('gutenberg: 解析格式与封面', async () => {
|
||||||
|
h.setHandler(h.routes([['gutendex.com/books', {
|
||||||
|
body: {
|
||||||
|
count: 64,
|
||||||
|
results: [{
|
||||||
|
id: 11, title: 'Alice', authors: [{ name: 'Carroll' }],
|
||||||
|
formats: { 'application/epub+zip': 'https://x/a.epub', 'image/jpeg': 'https://x/c.jpg' }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]]));
|
||||||
|
const r = await sources.getSource('gutenberg').search('alice', 1);
|
||||||
|
assert.strictEqual(r.items[0].postId, '11');
|
||||||
|
assert.strictEqual(r.items[0].cover, 'https://x/c.jpg');
|
||||||
|
assert.strictEqual(r.maxPage, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const h = require('./helpers');
|
||||||
|
|
||||||
|
h.installFetchStub();
|
||||||
|
h.setHandler(() => h.makeResponse({ status: 404 }));
|
||||||
|
|
||||||
|
const store = require('../library/store');
|
||||||
|
|
||||||
|
function tmpDir(tag) {
|
||||||
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-${tag}-`));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
const created = [];
|
||||||
|
function freshRoot(tag) {
|
||||||
|
const d = tmpDir(tag);
|
||||||
|
created.push(d);
|
||||||
|
store.init(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
test.after(() => {
|
||||||
|
for (const d of created) {
|
||||||
|
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('init 建出目录结构', () => {
|
||||||
|
const root = freshRoot('init');
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'files')));
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'covers')));
|
||||||
|
assert.strictEqual(store.getRoot(), path.resolve(root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add / get / list 往返', () => {
|
||||||
|
freshRoot('crud');
|
||||||
|
const it = store.add({ title: '测试书', authors: ['作者'], sourceId: 's', sourcePostId: 1 });
|
||||||
|
assert.ok(it.id);
|
||||||
|
const got = store.get(it.id);
|
||||||
|
assert.strictEqual(got.title, '测试书');
|
||||||
|
assert.strictEqual(got.sourcePostId, '1', 'sourcePostId 应统一为字符串');
|
||||||
|
assert.strictEqual(store.list().length, 1);
|
||||||
|
assert.ok(store.findBySource('s', 1), '数字 postId 应能匹配');
|
||||||
|
assert.ok(store.findBySource('s', '1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('批量导入本地文件可按上一级目录创建并复用书架', () => {
|
||||||
|
const root = freshRoot('local-import-shelves');
|
||||||
|
const source = path.join(root, 'source');
|
||||||
|
for (const folder of ['文学', '技术']) fs.mkdirSync(path.join(source, folder), { recursive: true });
|
||||||
|
const files = [
|
||||||
|
path.join(source, '文学', '小说.epub'),
|
||||||
|
path.join(source, '文学', '诗集.pdf'),
|
||||||
|
path.join(source, '技术', '手册.txt')
|
||||||
|
];
|
||||||
|
files.forEach((file, index) => fs.writeFileSync(file, `fixture-${index}`));
|
||||||
|
const existingLiterature = store.addShelf('文学');
|
||||||
|
const records = files.map((file) => ({
|
||||||
|
path: file,
|
||||||
|
name: path.basename(file),
|
||||||
|
format: path.extname(file).slice(1).toUpperCase(),
|
||||||
|
parentName: path.basename(path.dirname(file))
|
||||||
|
}));
|
||||||
|
const imported = store.importLocal(records, 'shelf');
|
||||||
|
assert.strictEqual(imported.added, 3);
|
||||||
|
assert.strictEqual(imported.skipped, 0);
|
||||||
|
assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name).sort(), ['技术', '文学']);
|
||||||
|
const literature = store.listShelves().find((shelf) => shelf.name === '文学');
|
||||||
|
assert.strictEqual(literature.id, existingLiterature.id);
|
||||||
|
assert.strictEqual(
|
||||||
|
store.list().filter((item) => item.shelfId === literature.id).length,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const repeated = store.importLocal(records, 'shelf');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
added: repeated.added,
|
||||||
|
skipped: repeated.skipped,
|
||||||
|
skippedDuplicates: repeated.skippedDuplicates
|
||||||
|
},
|
||||||
|
{ added: 0, skipped: 3, skippedDuplicates: 3 }
|
||||||
|
);
|
||||||
|
assert.strictEqual(store.list().length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本地导入按规范路径跳过书库中已有的同一文件', () => {
|
||||||
|
const root = freshRoot('local-import-same-path');
|
||||||
|
const source = path.join(root, 'source', 'same.pdf');
|
||||||
|
fs.mkdirSync(path.dirname(source), { recursive: true });
|
||||||
|
fs.writeFileSync(source, 'same-path-content');
|
||||||
|
const record = { path: source, name: 'same.pdf', parentName: 'source' };
|
||||||
|
|
||||||
|
assert.strictEqual(store.importLocal([record]).added, 1);
|
||||||
|
const repeated = store.importLocal([record]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
added: repeated.added,
|
||||||
|
skipped: repeated.skipped,
|
||||||
|
skippedDuplicates: repeated.skippedDuplicates
|
||||||
|
},
|
||||||
|
{ added: 0, skipped: 1, skippedDuplicates: 1 }
|
||||||
|
);
|
||||||
|
assert.strictEqual(store.list().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本地导入按文件字节跳过不同路径下的副本', () => {
|
||||||
|
const root = freshRoot('local-import-copy');
|
||||||
|
const original = path.join(root, 'original', 'first.pdf');
|
||||||
|
const copy = path.join(root, 'copy', 'renamed.pdf');
|
||||||
|
fs.mkdirSync(path.dirname(original), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(copy), { recursive: true });
|
||||||
|
fs.writeFileSync(original, 'identical-file-bytes');
|
||||||
|
fs.copyFileSync(original, copy);
|
||||||
|
|
||||||
|
assert.strictEqual(store.importLocal([{ path: original }]).added, 1);
|
||||||
|
const copied = store.importLocal([{
|
||||||
|
path: copy,
|
||||||
|
name: 'Completely Different Title.pdf',
|
||||||
|
title: 'Completely Different Title'
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
added: copied.added,
|
||||||
|
skipped: copied.skipped,
|
||||||
|
skippedDuplicates: copied.skippedDuplicates
|
||||||
|
},
|
||||||
|
{ added: 0, skipped: 1, skippedDuplicates: 1 }
|
||||||
|
);
|
||||||
|
assert.strictEqual(store.list().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本地导入保留同名但字节不同的版本', () => {
|
||||||
|
const root = freshRoot('local-import-editions');
|
||||||
|
const first = path.join(root, 'edition-one', 'Shared Title.pdf');
|
||||||
|
const second = path.join(root, 'edition-two', 'Shared Title.pdf');
|
||||||
|
fs.mkdirSync(path.dirname(first), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(second), { recursive: true });
|
||||||
|
fs.writeFileSync(first, 'edition-A');
|
||||||
|
fs.writeFileSync(second, 'edition-B');
|
||||||
|
|
||||||
|
const imported = store.importLocal([
|
||||||
|
{ path: first, title: 'Shared Title' },
|
||||||
|
{ path: second, title: 'Shared Title' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
added: imported.added,
|
||||||
|
skipped: imported.skipped,
|
||||||
|
skippedDuplicates: imported.skippedDuplicates
|
||||||
|
},
|
||||||
|
{ added: 2, skipped: 0, skippedDuplicates: 0 }
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(store.list().map((item) => item.title), ['Shared Title', 'Shared Title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('混合批量导入同时跳过已有路径、已有副本和批内副本', () => {
|
||||||
|
const root = freshRoot('local-import-mixed');
|
||||||
|
const existing = path.join(root, 'existing', 'book.epub');
|
||||||
|
const existingCopy = path.join(root, 'incoming', 'existing-copy.epub');
|
||||||
|
const fresh = path.join(root, 'incoming', 'fresh.epub');
|
||||||
|
const freshCopy = path.join(root, 'incoming-copy', 'fresh-copy.epub');
|
||||||
|
for (const file of [existing, existingCopy, fresh, freshCopy]) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(existing, 'existing-content');
|
||||||
|
fs.copyFileSync(existing, existingCopy);
|
||||||
|
fs.writeFileSync(fresh, 'brand-new-content');
|
||||||
|
fs.copyFileSync(fresh, freshCopy);
|
||||||
|
store.importLocal([{ path: existing }]);
|
||||||
|
|
||||||
|
const imported = store.importLocal([
|
||||||
|
{ path: existing },
|
||||||
|
{ path: existingCopy },
|
||||||
|
{ path: fresh },
|
||||||
|
{ path: freshCopy }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
added: imported.added,
|
||||||
|
skipped: imported.skipped,
|
||||||
|
skippedDuplicates: imported.skippedDuplicates
|
||||||
|
},
|
||||||
|
{ added: 1, skipped: 3, skippedDuplicates: 3 }
|
||||||
|
);
|
||||||
|
assert.strictEqual(imported.items[0].files[0].path, fs.realpathSync(fresh));
|
||||||
|
assert.strictEqual(store.list().length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本地批量导入写入失败时回滚条目、分类和去重状态', () => {
|
||||||
|
const root = freshRoot('local-import-rollback');
|
||||||
|
const source = path.join(root, '回滚分类');
|
||||||
|
const first = path.join(source, 'first.pdf');
|
||||||
|
const duplicate = path.join(source, 'first-copy.pdf');
|
||||||
|
const second = path.join(source, 'second.pdf');
|
||||||
|
fs.mkdirSync(source, { recursive: true });
|
||||||
|
fs.writeFileSync(first, 'duplicate-content');
|
||||||
|
fs.copyFileSync(first, duplicate);
|
||||||
|
fs.writeFileSync(second, 'distinct-content');
|
||||||
|
const records = [first, duplicate, second].map((file) => ({
|
||||||
|
path: file,
|
||||||
|
parentName: '回滚分类'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const file = path.join(root, 'library.json');
|
||||||
|
const originalRename = fs.renameSync;
|
||||||
|
let failed = false;
|
||||||
|
fs.renameSync = function renameWithFailure(sourcePath, destination) {
|
||||||
|
if (!failed && sourcePath === `${file}.tmp` && destination === file) {
|
||||||
|
failed = true;
|
||||||
|
throw new Error('simulated replace failure');
|
||||||
|
}
|
||||||
|
return originalRename.apply(this, arguments);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() => store.importLocal(records, 'shelf'),
|
||||||
|
/书库索引写入失败/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.renameSync = originalRename;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(failed);
|
||||||
|
assert.deepStrictEqual(store.list(), []);
|
||||||
|
assert.deepStrictEqual(store.listShelves(), []);
|
||||||
|
assert.ok(!fs.existsSync(file));
|
||||||
|
assert.ok(!fs.existsSync(`${file}.tmp`));
|
||||||
|
|
||||||
|
const retried = store.importLocal(records, 'shelf');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
added: retried.added,
|
||||||
|
skipped: retried.skipped,
|
||||||
|
skippedDuplicates: retried.skippedDuplicates
|
||||||
|
},
|
||||||
|
{ added: 2, skipped: 1, skippedDuplicates: 1 }
|
||||||
|
);
|
||||||
|
assert.strictEqual(store.list().length, 2);
|
||||||
|
assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name), ['回滚分类']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('批量导入本地文件可按上一级目录创建标签或保持不分类', () => {
|
||||||
|
const root = freshRoot('local-import-tags');
|
||||||
|
const folder = path.join(root, '旧分类');
|
||||||
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
const taggedFile = path.join(folder, '标签书.pdf');
|
||||||
|
const plainFile = path.join(folder, '普通书.epub');
|
||||||
|
fs.writeFileSync(taggedFile, 'tagged');
|
||||||
|
fs.writeFileSync(plainFile, 'plain');
|
||||||
|
const existingTag = store.addTag('旧分类');
|
||||||
|
const tagged = store.importLocal([{
|
||||||
|
path: taggedFile,
|
||||||
|
name: '标签书.pdf',
|
||||||
|
parentName: '旧分类'
|
||||||
|
}], 'tag');
|
||||||
|
assert.strictEqual(tagged.added, 1);
|
||||||
|
assert.deepStrictEqual(store.get(tagged.items[0].id).tags, ['旧分类']);
|
||||||
|
const taggedCatalog = store.listTags().find((tag) => tag.name === '旧分类');
|
||||||
|
assert.strictEqual(taggedCatalog.id, existingTag.id);
|
||||||
|
assert.strictEqual(taggedCatalog.count, 1);
|
||||||
|
|
||||||
|
const plain = store.importLocal([{
|
||||||
|
path: plainFile,
|
||||||
|
name: '普通书.epub',
|
||||||
|
parentName: '旧分类'
|
||||||
|
}], 'none');
|
||||||
|
assert.strictEqual(plain.added, 1);
|
||||||
|
assert.deepStrictEqual(store.get(plain.items[0].id).tags, []);
|
||||||
|
assert.strictEqual(store.get(plain.items[0].id).shelfId, null);
|
||||||
|
assert.throws(() => store.importLocal([], 'invalid'), /分类方式无效/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书库内文件存相对路径,外部文件存绝对路径', () => {
|
||||||
|
const root = freshRoot('paths');
|
||||||
|
const inside = path.join(root, 'files', 'a.pdf');
|
||||||
|
fs.writeFileSync(inside, 'x');
|
||||||
|
const outsideDir = tmpDir('outside');
|
||||||
|
created.push(outsideDir);
|
||||||
|
const outside = path.join(outsideDir, 'b.pdf');
|
||||||
|
fs.writeFileSync(outside, 'y');
|
||||||
|
|
||||||
|
const it = store.add({ title: 'T', files: [{ path: inside }, { path: outside }] });
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
|
||||||
|
const stored = raw.items[0].files.map((f) => f.path);
|
||||||
|
assert.ok(stored.includes('files/a.pdf'), '库内文件未转相对路径: ' + stored);
|
||||||
|
assert.ok(stored.some((p) => path.isAbsolute(p)), '库外文件不应转相对路径');
|
||||||
|
|
||||||
|
// 对外一律给绝对路径
|
||||||
|
for (const f of it.files) assert.ok(path.isAbsolute(f.path), f.path);
|
||||||
|
assert.ok(it.files.every((f) => f.exists));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expand 如实反映磁盘状态', () => {
|
||||||
|
const root = freshRoot('missing');
|
||||||
|
const p = path.join(root, 'files', 'gone.pdf');
|
||||||
|
fs.writeFileSync(p, 'x');
|
||||||
|
const it = store.add({ title: 'T', files: [{ path: p }] });
|
||||||
|
assert.strictEqual(store.get(it.id).missing, false);
|
||||||
|
fs.unlinkSync(p);
|
||||||
|
const after = store.get(it.id);
|
||||||
|
assert.strictEqual(after.files[0].exists, false);
|
||||||
|
assert.strictEqual(after.missing, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allocFilePath 避免覆盖同名文件', () => {
|
||||||
|
const root = freshRoot('alloc');
|
||||||
|
const first = store.allocFilePath('book.pdf');
|
||||||
|
fs.writeFileSync(first, 'a');
|
||||||
|
const second = store.allocFilePath('book.pdf');
|
||||||
|
assert.notStrictEqual(first, second);
|
||||||
|
assert.ok(second.includes('(1)'), second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitize 去掉非法字符', () => {
|
||||||
|
assert.strictEqual(store.sanitize('a/b:c*d?.pdf'), 'a_b_c_d_.pdf');
|
||||||
|
assert.strictEqual(store.sanitize(''), 'download');
|
||||||
|
assert.strictEqual(store.sanitize(' '), 'download');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove 默认保留文件,deleteFiles 才删', () => {
|
||||||
|
const root = freshRoot('remove');
|
||||||
|
const p = path.join(root, 'files', 'keep.pdf');
|
||||||
|
fs.writeFileSync(p, 'x');
|
||||||
|
const a = store.add({ title: 'A', files: [{ path: p }] });
|
||||||
|
store.remove(a.id, false);
|
||||||
|
assert.ok(fs.existsSync(p), '未勾选删除时不该删文件');
|
||||||
|
|
||||||
|
const b = store.add({ title: 'B', files: [{ path: p }] });
|
||||||
|
store.remove(b.id, true);
|
||||||
|
assert.ok(!fs.existsSync(p), '勾选删除后文件应被删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove 不删书库目录外的用户文件', () => {
|
||||||
|
freshRoot('remove-outside');
|
||||||
|
const outDir = tmpDir('user');
|
||||||
|
created.push(outDir);
|
||||||
|
const p = path.join(outDir, 'mine.pdf');
|
||||||
|
fs.writeFileSync(p, 'x');
|
||||||
|
const it = store.add({ title: 'T', files: [{ path: p }] });
|
||||||
|
store.remove(it.id, true);
|
||||||
|
assert.ok(fs.existsSync(p), '原地引用的外部文件被误删了');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan 导入孤立文件并跳过非书籍扩展名', () => {
|
||||||
|
const root = freshRoot('scan');
|
||||||
|
fs.writeFileSync(path.join(root, 'files', 'novel.epub'), 'x');
|
||||||
|
fs.writeFileSync(path.join(root, 'files', 'notes.exe'), 'x');
|
||||||
|
const r = store.scan();
|
||||||
|
assert.strictEqual(r.added, 1, '应只导入 epub');
|
||||||
|
assert.strictEqual(store.list()[0].title, 'novel');
|
||||||
|
const again = store.scan();
|
||||||
|
assert.strictEqual(again.added, 0, '重复扫描不应重复导入');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachFile 幂等,不产生重复条目文件', () => {
|
||||||
|
const root = freshRoot('attach');
|
||||||
|
const it = store.add({ title: 'T' });
|
||||||
|
const p = path.join(root, 'files', 'x.pdf');
|
||||||
|
fs.writeFileSync(p, 'x');
|
||||||
|
store.attachFile(it.id, p);
|
||||||
|
const after = store.attachFile(it.id, p);
|
||||||
|
assert.strictEqual(after.files.length, 1, '重复挂载产生了重复记录');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('生成封面写入 covers 并随条目删除', () => {
|
||||||
|
const root = freshRoot('generated-cover');
|
||||||
|
const it = store.add({ title: 'T' });
|
||||||
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
|
||||||
|
const cover = store.setGeneratedCover(it.id, jpeg);
|
||||||
|
assert.ok(cover.startsWith(path.join(root, 'covers')), cover);
|
||||||
|
assert.ok(fs.existsSync(cover));
|
||||||
|
assert.strictEqual(store.get(it.id).cover, cover);
|
||||||
|
store.remove(it.id, false);
|
||||||
|
assert.ok(!fs.existsSync(cover), '移除条目后遗留了生成封面');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('生成封面不覆盖更新后的来源封面', () => {
|
||||||
|
const root = freshRoot('generated-priority');
|
||||||
|
const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
|
||||||
|
store.update(it.id, { cover: 'https://new.example/cover.jpg' });
|
||||||
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
|
||||||
|
assert.strictEqual(store.setGeneratedCover(it.id, jpeg, it.cover), '');
|
||||||
|
assert.strictEqual(store.get(it.id).cover, 'https://new.example/cover.jpg');
|
||||||
|
|
||||||
|
const blank = store.add({ title: 'Blank' });
|
||||||
|
const manual = path.join(root, 'manual.jpg');
|
||||||
|
fs.writeFileSync(manual, jpeg);
|
||||||
|
store.update(blank.id, { cover: manual });
|
||||||
|
assert.strictEqual(store.setGeneratedCover(blank.id, jpeg, ''), '');
|
||||||
|
assert.strictEqual(store.get(blank.id).cover, manual);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('生成封面拒绝非 JPEG 和过大数据', () => {
|
||||||
|
freshRoot('generated-validation');
|
||||||
|
const it = store.add({ title: 'T' });
|
||||||
|
assert.throws(() => store.setGeneratedCover(it.id, Buffer.from('not an image')), /JPEG/);
|
||||||
|
const large = Buffer.alloc(2 * 1024 * 1024 + 1);
|
||||||
|
large[0] = 0xff; large[1] = 0xd8; large[2] = 0xff;
|
||||||
|
assert.throws(() => store.setGeneratedCover(it.id, large), /JPEG/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('远程封面下载完成后不覆盖期间更新的封面', async () => {
|
||||||
|
const root = freshRoot('remote-cover-race');
|
||||||
|
let release;
|
||||||
|
let startedResolve;
|
||||||
|
const started = new Promise((resolve) => { startedResolve = resolve; });
|
||||||
|
h.setHandler(() => {
|
||||||
|
startedResolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
release = () => resolve({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]).buffer
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
|
||||||
|
const job = store.ensureCoverCached(it.id);
|
||||||
|
await started;
|
||||||
|
const manual = path.join(root, 'manual.jpg');
|
||||||
|
fs.writeFileSync(manual, Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||||
|
store.update(it.id, { cover: manual });
|
||||||
|
release();
|
||||||
|
assert.strictEqual(await job, '');
|
||||||
|
assert.strictEqual(store.get(it.id).cover, manual);
|
||||||
|
h.setHandler(() => h.makeResponse({ status: 404 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('远程封面缓存拒绝网页响应', async () => {
|
||||||
|
const root = freshRoot('remote-cover-html');
|
||||||
|
h.setHandler(() => ({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => Uint8Array.from(Buffer.from('<html>not an image</html>')).buffer
|
||||||
|
}));
|
||||||
|
const it = store.add({ title: 'T', cover: 'https://example.com/cover.jpg' });
|
||||||
|
assert.strictEqual(await store.ensureCoverCached(it.id), '');
|
||||||
|
assert.strictEqual(store.get(it.id).cover, 'https://example.com/cover.jpg');
|
||||||
|
assert.deepStrictEqual(fs.readdirSync(path.join(root, 'covers')), []);
|
||||||
|
h.setHandler(() => h.makeResponse({ status: 404 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('索引损坏时报错而不是静默清空书库', () => {
|
||||||
|
const root = freshRoot('corrupt');
|
||||||
|
store.add({ title: '重要的书' });
|
||||||
|
fs.writeFileSync(path.join(root, 'library.json'), '{ 坏掉的 json');
|
||||||
|
store.init(root);
|
||||||
|
assert.throws(() => store.list(), /书库索引读取失败/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('写入后可从 .bak 恢复', () => {
|
||||||
|
const root = freshRoot('bak');
|
||||||
|
store.add({ title: '书' });
|
||||||
|
const idx = path.join(root, 'library.json');
|
||||||
|
fs.copyFileSync(idx, idx + '.bak');
|
||||||
|
fs.unlinkSync(idx);
|
||||||
|
store.init(root);
|
||||||
|
assert.strictEqual(store.list().length, 1, '未从 .bak 恢复');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateTo 搬运文件并保持条目可用', () => {
|
||||||
|
const src = freshRoot('mig-src');
|
||||||
|
const shelf = store.addShelf({ name: '迁移书架' });
|
||||||
|
const p = path.join(src, 'files', 'm.pdf');
|
||||||
|
fs.writeFileSync(p, 'data');
|
||||||
|
const added = store.add({ title: 'M', shelfId: shelf.id, tags: ['迁移'], files: [{ path: p }] });
|
||||||
|
const oldCover = store.setGeneratedCover(
|
||||||
|
added.id,
|
||||||
|
Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9])
|
||||||
|
);
|
||||||
|
|
||||||
|
const dest = tmpDir('mig-dest');
|
||||||
|
created.push(dest);
|
||||||
|
store.migrateTo(dest);
|
||||||
|
store.finalizeMigration();
|
||||||
|
|
||||||
|
assert.strictEqual(store.getRoot(), path.resolve(dest));
|
||||||
|
const items = store.list();
|
||||||
|
assert.strictEqual(items.length, 1);
|
||||||
|
assert.ok(items[0].files[0].exists, '迁移后文件丢失');
|
||||||
|
assert.ok(items[0].files[0].path.startsWith(path.resolve(dest)), items[0].files[0].path);
|
||||||
|
assert.ok(items[0].cover.startsWith(path.resolve(dest)), items[0].cover);
|
||||||
|
assert.ok(fs.existsSync(items[0].cover), '迁移后生成封面丢失');
|
||||||
|
assert.strictEqual(items[0].shelfId, shelf.id);
|
||||||
|
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['迁移书架']);
|
||||||
|
assert.ok(!fs.existsSync(p), '旧文件未清理');
|
||||||
|
assert.ok(!fs.existsSync(oldCover), '旧生成封面未清理');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateTo 拒绝互相包含的目录', () => {
|
||||||
|
const src = freshRoot('mig-nest');
|
||||||
|
assert.throws(() => store.migrateTo(path.join(src, 'sub')), /不能互相包含/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateTo 拒绝已有书库的目标目录', () => {
|
||||||
|
freshRoot('mig-occupied');
|
||||||
|
store.add({ title: 'A' });
|
||||||
|
const dest = tmpDir('mig-taken');
|
||||||
|
created.push(dest);
|
||||||
|
fs.writeFileSync(path.join(dest, 'library.json'), '{}');
|
||||||
|
assert.throws(() => store.migrateTo(dest), /已包含书库索引/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rollbackMigration 回到原目录且不留残File', () => {
|
||||||
|
const src = freshRoot('mig-rb');
|
||||||
|
const shelf = store.addShelf('回滚书架');
|
||||||
|
const p = path.join(src, 'files', 'r.pdf');
|
||||||
|
fs.writeFileSync(p, 'data');
|
||||||
|
store.add({ title: 'R', shelfId: shelf.id, files: [{ path: p }] });
|
||||||
|
|
||||||
|
const dest = tmpDir('mig-rb-dest');
|
||||||
|
created.push(dest);
|
||||||
|
store.migrateTo(dest);
|
||||||
|
store.rollbackMigration();
|
||||||
|
|
||||||
|
assert.strictEqual(store.getRoot(), path.resolve(src));
|
||||||
|
assert.ok(fs.existsSync(p), '回滚后源文件应还在');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'library.json')), '目标目录索引未清理');
|
||||||
|
assert.strictEqual(store.list().length, 1);
|
||||||
|
assert.strictEqual(store.list()[0].shelfId, shelf.id);
|
||||||
|
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['回滚书架']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importLegacy 正确复制相对路径封面', () => {
|
||||||
|
const legacy = tmpDir('legacy-relative-cover');
|
||||||
|
created.push(legacy);
|
||||||
|
fs.mkdirSync(path.join(legacy, 'covers'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacy, 'covers', 'old.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||||
|
fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify([{
|
||||||
|
id: 'legacy-book',
|
||||||
|
title: 'Legacy',
|
||||||
|
cover: 'covers/old.jpg',
|
||||||
|
files: []
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const root = freshRoot('legacy-relative-dest');
|
||||||
|
assert.strictEqual(store.importLegacy(legacy).imported, 1);
|
||||||
|
const imported = store.get('legacy-book');
|
||||||
|
assert.ok(imported.cover.startsWith(path.join(root, 'covers')), imported.cover);
|
||||||
|
assert.ok(fs.existsSync(imported.cover));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update 修改字段并刷新 updatedAt', () => {
|
||||||
|
freshRoot('update');
|
||||||
|
const it = store.add({ title: '旧' });
|
||||||
|
const out = store.update(it.id, { title: '新', tags: ['t'] });
|
||||||
|
assert.strictEqual(out.title, '新');
|
||||||
|
assert.deepStrictEqual(out.tags, ['t']);
|
||||||
|
assert.throws(() => store.update('nope', {}), /条目不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v1 和 v2 索引透明迁移到 v4 并保留条目与标签目录', () => {
|
||||||
|
for (const fixture of [
|
||||||
|
{
|
||||||
|
tag: 'schema-v1',
|
||||||
|
data: [{ id: 'v1', title: '旧数组', custom: { kept: true }, tags: [' A ', 'a', ''] }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'schema-v2',
|
||||||
|
data: {
|
||||||
|
version: 2,
|
||||||
|
items: [{ id: 'v2', title: '旧对象', custom: { kept: true }, tags: ['B'], shelfId: 'missing' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]) {
|
||||||
|
const root = freshRoot(fixture.tag);
|
||||||
|
fs.writeFileSync(path.join(root, 'library.json'), JSON.stringify(fixture.data));
|
||||||
|
store.init(root);
|
||||||
|
const item = store.list()[0];
|
||||||
|
assert.deepStrictEqual(item.custom, { kept: true });
|
||||||
|
assert.strictEqual(item.shelfId, null);
|
||||||
|
assert.strictEqual(item.tags.length, 1);
|
||||||
|
|
||||||
|
store.update(item.id, { title: item.title });
|
||||||
|
const persisted = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
|
||||||
|
assert.strictEqual(persisted.version, 4);
|
||||||
|
assert.deepStrictEqual(persisted.shelves, []);
|
||||||
|
assert.strictEqual(persisted.tags.length, 1);
|
||||||
|
assert.strictEqual(persisted.tags[0].name, item.tags[0]);
|
||||||
|
assert.deepStrictEqual(persisted.items[0].custom, { kept: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书架 CRUD 强制唯一非空名称并返回深拷贝', () => {
|
||||||
|
freshRoot('shelf-crud');
|
||||||
|
const shelf = store.addShelf({ name: ' 技术 ' });
|
||||||
|
assert.match(shelf.id, /^shelf_[a-f0-9]{24}$/);
|
||||||
|
assert.strictEqual(shelf.name, '技术');
|
||||||
|
assert.ok(Number.isFinite(shelf.createdAt));
|
||||||
|
assert.ok(Number.isFinite(shelf.updatedAt));
|
||||||
|
assert.throws(() => store.addShelf(' '), /不能为空/);
|
||||||
|
assert.throws(() => store.addShelf('技术'), /已存在/);
|
||||||
|
|
||||||
|
const listed = store.listShelves();
|
||||||
|
listed[0].name = '被外部修改';
|
||||||
|
listed.push({ id: 'fake', name: '假的' });
|
||||||
|
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['技术']);
|
||||||
|
|
||||||
|
const updated = store.updateShelf(shelf.id, { name: ' 文学 ' });
|
||||||
|
assert.strictEqual(updated.name, '文学');
|
||||||
|
assert.strictEqual(updated.createdAt, shelf.createdAt);
|
||||||
|
assert.ok(updated.updatedAt >= shelf.updatedAt);
|
||||||
|
updated.name = '再次外部修改';
|
||||||
|
assert.strictEqual(store.listShelves()[0].name, '文学');
|
||||||
|
assert.throws(() => store.updateShelf('missing', { name: 'X' }), /不存在/);
|
||||||
|
|
||||||
|
const other = store.addShelf('Research');
|
||||||
|
assert.throws(() => store.addShelf(' research '), /已存在/);
|
||||||
|
assert.throws(() => store.updateShelf(other.id, { name: ' 文学 ' }), /已存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('删除书架只清空条目 shelfId 且组织变更触发通知', () => {
|
||||||
|
freshRoot('shelf-remove');
|
||||||
|
let changes = 0;
|
||||||
|
store.setChangeListener(() => { changes++; });
|
||||||
|
try {
|
||||||
|
const shelf = store.addShelf('待整理');
|
||||||
|
const book = store.add({ title: '保留我' });
|
||||||
|
store.update(book.id, { shelfId: shelf.id, tags: [' A ', 'a', 'B'] });
|
||||||
|
assert.strictEqual(changes, 2, '书架添加和组织更新均应通知');
|
||||||
|
assert.strictEqual(store.get(book.id).shelfId, shelf.id);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: true });
|
||||||
|
assert.strictEqual(changes, 3);
|
||||||
|
assert.strictEqual(store.list().length, 1, '删除书架不应删除书籍');
|
||||||
|
assert.strictEqual(store.get(book.id).shelfId, null);
|
||||||
|
assert.deepStrictEqual(store.get(book.id).tags, ['A', 'B']);
|
||||||
|
assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: false });
|
||||||
|
assert.strictEqual(changes, 3, '重复删除不存在的书架不应通知');
|
||||||
|
} finally {
|
||||||
|
store.setChangeListener(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('条目组织字段归一化并限制标签数量和长度', () => {
|
||||||
|
freshRoot('organization-normalize');
|
||||||
|
const shelf = store.addShelf('有效书架');
|
||||||
|
const manyTags = Array.from({ length: 60 }, (_, i) => ` tag-${i} `);
|
||||||
|
const book = store.add({
|
||||||
|
title: '组织',
|
||||||
|
shelfId: shelf.id,
|
||||||
|
tags: [' Foo ', 'foo', null, '', 'x'.repeat(80), ...manyTags]
|
||||||
|
});
|
||||||
|
assert.strictEqual(book.shelfId, shelf.id);
|
||||||
|
assert.strictEqual(book.tags[0], 'Foo');
|
||||||
|
assert.strictEqual(book.tags[1].length, 64);
|
||||||
|
assert.strictEqual(book.tags.length, 50);
|
||||||
|
|
||||||
|
const invalid = store.update(book.id, { shelfId: 'not-a-shelf', tags: 'not-an-array' });
|
||||||
|
assert.strictEqual(invalid.shelfId, null);
|
||||||
|
assert.deepStrictEqual(invalid.tags, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listTags 合并大小写、保留稳定 ID 并按数量和中文名称排序', () => {
|
||||||
|
freshRoot('tag-catalog');
|
||||||
|
store.add({ title: '一', tags: [' 科学 ', 'SCIENCE', '历史'] });
|
||||||
|
store.add({ title: '二', tags: ['科学', 'science', '文学'] });
|
||||||
|
store.add({ title: '三', tags: ['Science'] });
|
||||||
|
|
||||||
|
const actual = store.listTags();
|
||||||
|
assert.ok(actual.every((tag) => /^tag_[a-f0-9]{24}$/.test(tag.id)));
|
||||||
|
assert.deepStrictEqual(actual.slice(0, 2).map(({ name, count }) => ({ name, count })), [
|
||||||
|
{ name: 'SCIENCE', count: 3 },
|
||||||
|
{ name: '科学', count: 2 }
|
||||||
|
]);
|
||||||
|
const tied = actual.slice(2).map(({ name, count }) => ({ name, count }));
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
tied,
|
||||||
|
[{ name: '历史', count: 1 }, { name: '文学', count: 1 }]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importLegacy 保留书架、标签并将同名书架映射到现有书架', () => {
|
||||||
|
const legacy = tmpDir('legacy-shelves');
|
||||||
|
created.push(legacy);
|
||||||
|
fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify({
|
||||||
|
version: 3,
|
||||||
|
shelves: [
|
||||||
|
{ id: 'old-shared', name: ' 已有 ', createdAt: 10, updatedAt: 20 },
|
||||||
|
{ id: 'old-new', name: '新书架', createdAt: 30, updatedAt: 40 }
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{ id: 'legacy-shared-book', title: '共享', shelfId: 'old-shared', tags: [' A ', 'a'] },
|
||||||
|
{ id: 'legacy-new-book', title: '新增', shelfId: 'old-new', tags: [' B '] }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
freshRoot('legacy-shelves-dest');
|
||||||
|
const existing = store.addShelf('已有');
|
||||||
|
assert.strictEqual(store.importLegacy(legacy).imported, 2);
|
||||||
|
const importedShelves = store.listShelves();
|
||||||
|
assert.deepStrictEqual(importedShelves.map((entry) => entry.name), ['已有', '新书架']);
|
||||||
|
assert.strictEqual(importedShelves[1].createdAt, 30);
|
||||||
|
assert.strictEqual(store.get('legacy-shared-book').shelfId, existing.id);
|
||||||
|
assert.strictEqual(store.get('legacy-new-book').shelfId, importedShelves[1].id);
|
||||||
|
assert.deepStrictEqual(store.get('legacy-shared-book').tags, ['A']);
|
||||||
|
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(store.getRoot(), 'library.json'), 'utf8'));
|
||||||
|
assert.strictEqual(raw.version, 4);
|
||||||
|
assert.strictEqual(raw.shelves.length, 2);
|
||||||
|
assert.deepStrictEqual(raw.tags.map((tag) => tag.name).sort(), ['A', 'B']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
// TXT / Markdown 适配器单测。适配器本体是渲染层 ESM,这里用 jsdom 提供真实的
|
||||||
|
// window / document / DOMParser,并加载仓库里真正会随包发布的 vendor 脚本,
|
||||||
|
// 这样断言的是「真正交给 epub 适配器渲染的产物」,而不是桩。
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', 'ui', 'reader', 'text-adapter.mjs');
|
||||||
|
const VENDOR = path.join(__dirname, '..', 'ui', 'vendor');
|
||||||
|
const XHTML = 'application/xhtml+xml';
|
||||||
|
|
||||||
|
function setupDom() {
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||||
|
const win = dom.window;
|
||||||
|
win.JSZip = require(path.join(VENDOR, 'jszip.min.js'));
|
||||||
|
win.markdownit = require(path.join(VENDOR, 'markdown-it.min.js'));
|
||||||
|
const purifyFactory = require(path.join(VENDOR, 'purify.min.js'));
|
||||||
|
win.DOMPurify = typeof purifyFactory === 'function' ? purifyFactory(win) : purifyFactory;
|
||||||
|
for (const key of ['window', 'document', 'DOMParser', 'XMLSerializer', 'NodeFilter', 'Node', 'Range']) {
|
||||||
|
globalThis[key] = key === 'window' ? win : win[key];
|
||||||
|
}
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = setupDom();
|
||||||
|
const mod = import('../ui/reader/text-adapter.mjs');
|
||||||
|
|
||||||
|
function u8(...parts) {
|
||||||
|
const buffers = parts.map((part) => (typeof part === 'string'
|
||||||
|
? Buffer.from(part, 'utf8')
|
||||||
|
: Buffer.from(part)));
|
||||||
|
return new Uint8Array(Buffer.concat(buffers));
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf16Bytes(text, littleEndian, bom) {
|
||||||
|
const out = [];
|
||||||
|
if (bom) out.push(...(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]));
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const code = text.charCodeAt(i);
|
||||||
|
const hi = code >> 8;
|
||||||
|
const lo = code & 0xff;
|
||||||
|
out.push(...(littleEndian ? [lo, hi] : [hi, lo]));
|
||||||
|
}
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// tidy 与 epub 适配器里的同名函数一致,textOf 返回的是 tidy 之后的文本
|
||||||
|
function tidy(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.replace(/[ \t\f\v\u00a0]+/g, ' ')
|
||||||
|
.replace(/ ?\n ?/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function novel(sections) {
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 1; i <= sections; i++) {
|
||||||
|
parts.push(`第${i}章 标题${i}\n【标记${String(i).padStart(4, '0')}】这一节的正文内容,用来验证全文不缺不重。\n`);
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChapter(zipBytes, index) {
|
||||||
|
const zip = await win.JSZip.loadAsync(zipBytes);
|
||||||
|
const file = zip.file(`text/chapter-${index}.xhtml`);
|
||||||
|
assert.ok(file, `产物里应有 text/chapter-${index}.xhtml`);
|
||||||
|
return file.async('text');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXhtml(text) {
|
||||||
|
const doc = new win.DOMParser().parseFromString(text, XHTML);
|
||||||
|
assert.ok(doc && doc.body && !doc.querySelector('parsererror'),
|
||||||
|
'生成的章节必须是合法 XHTML,否则 epub 适配器会退回容错 HTML 解析,行为不可预测');
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditUntrusted(doc, where) {
|
||||||
|
for (const selector of ['script', 'iframe', 'object', 'embed', 'img', 'link', 'meta', 'form', 'base', 'a']) {
|
||||||
|
assert.strictEqual(doc.querySelectorAll(selector).length, 0,
|
||||||
|
`${where} 不允许出现 <${selector}>:这是不可信内容的注入面`);
|
||||||
|
}
|
||||||
|
for (const el of doc.querySelectorAll('*')) {
|
||||||
|
for (const attr of Array.from(el.attributes)) {
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
assert.ok(!name.startsWith('on'),
|
||||||
|
`${where} 出现事件属性 ${name},脚本会在阅读器里执行`);
|
||||||
|
assert.ok(!['href', 'src', 'xlink:href', 'srcset', 'poster', 'style', 'formaction'].includes(name),
|
||||||
|
`${where} 出现可发起加载或导航的属性 ${name}`);
|
||||||
|
assert.ok(!/(?:javascript|vbscript|data|file):/i.test(attr.value),
|
||||||
|
`${where} 属性 ${name} 里出现危险协议`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 编码检测与解码 --- */
|
||||||
|
|
||||||
|
test('UTF-8 与 UTF-16 的 BOM 都能正确剥离,BOM 不得留在正文里', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
const text = '中文标题\n正文第一行\n';
|
||||||
|
|
||||||
|
const plain = decodeTextBytes(u8(text));
|
||||||
|
assert.strictEqual(plain.text, text, 'UTF-8 无 BOM 必须原样解出');
|
||||||
|
assert.strictEqual(plain.encoding, 'utf-8');
|
||||||
|
assert.strictEqual(plain.confident, true);
|
||||||
|
|
||||||
|
const withBom = decodeTextBytes(u8([0xef, 0xbb, 0xbf], text));
|
||||||
|
assert.strictEqual(withBom.text, text, 'UTF-8 BOM 必须被剥掉,留着会变成正文首个不可见字符');
|
||||||
|
assert.ok(!withBom.text.includes('\ufeff'), 'U+FEFF 不能出现在正文里');
|
||||||
|
|
||||||
|
const le = decodeTextBytes(utf16Bytes(text, true, true));
|
||||||
|
assert.strictEqual(le.text, text, 'UTF-16LE 带 BOM 必须正确解出中文');
|
||||||
|
assert.strictEqual(le.encoding, 'utf-16le');
|
||||||
|
assert.ok(!le.text.includes('\ufeff'));
|
||||||
|
|
||||||
|
const be = decodeTextBytes(utf16Bytes(text, false, true));
|
||||||
|
assert.strictEqual(be.text, text, 'UTF-16BE 带 BOM 必须正确解出中文');
|
||||||
|
assert.strictEqual(be.encoding, 'utf-16be');
|
||||||
|
assert.ok(!be.text.includes('\ufeff'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('双 BOM 也要剥干净:TextDecoder 只吃掉第一个', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
// 某些编辑器另存为会叠一层 BOM。TextDecoder 只认最前面那个,第二个会变成正文首字符,
|
||||||
|
// 章节标题匹配随即失效,目录莫名少一章。
|
||||||
|
const text = '第1章 标题\n正文\n';
|
||||||
|
assert.strictEqual(decodeTextBytes(u8([0xef, 0xbb, 0xbf], [0xef, 0xbb, 0xbf], text)).text, text);
|
||||||
|
const le = utf16Bytes(text, true, true);
|
||||||
|
assert.strictEqual(decodeTextBytes(u8([0xff, 0xfe], le)).text, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无 BOM 的 UTF-16LE 靠 NUL 分布嗅探,不能当成乱码', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
const text = 'Chapter 1\nHello world, this is plain ASCII text stored as UTF-16LE.\n';
|
||||||
|
const out = decodeTextBytes(utf16Bytes(text, true, false));
|
||||||
|
assert.strictEqual(out.encoding, 'utf-16le');
|
||||||
|
assert.strictEqual(out.text, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GBK 字节走 gb18030 解码,不产生替换字符', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
// 「这是一段简体中文测试」的 GBK 编码
|
||||||
|
const gbk = new Uint8Array([
|
||||||
|
0xd5, 0xe2, 0xca, 0xc7, 0xd2, 0xbb, 0xb6, 0xce, 0xbc, 0xf2,
|
||||||
|
0xcc, 0xe5, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4,
|
||||||
|
0x0a
|
||||||
|
]);
|
||||||
|
const out = decodeTextBytes(gbk);
|
||||||
|
assert.strictEqual(out.text, '这是一段简体中文测试\n', 'GBK 必须解成可读中文');
|
||||||
|
assert.strictEqual(out.encoding, 'gb18030');
|
||||||
|
assert.strictEqual(out.confident, true);
|
||||||
|
assert.ok(!out.text.includes('\ufffd'), '解对了就不该有 U+FFFD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('解不出任何像样编码时标记 confident=false,不让用户对着乱码猜', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
// 0x81 后跟 0x1f 在 GBK / Big5 里都是非法尾字节,cp1252 里则落进 C1 控制区
|
||||||
|
const noise = new Uint8Array(512);
|
||||||
|
for (let i = 0; i < noise.length; i++) noise[i] = i % 2 ? 0x1f : 0x81;
|
||||||
|
const out = decodeTextBytes(noise);
|
||||||
|
assert.strictEqual(out.confident, false, '低置信度必须上报,否则界面会静默显示乱码');
|
||||||
|
|
||||||
|
for (const [name, bytes] of [
|
||||||
|
['UTF-8 中文', u8('正常的中文文本内容。\n')],
|
||||||
|
['GBK 中文', new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0x0a])],
|
||||||
|
['纯 ASCII', u8('Plain english text.\n')]
|
||||||
|
]) {
|
||||||
|
assert.strictEqual(decodeTextBytes(bytes).confident, true,
|
||||||
|
`${name} 不能被误判成低置信度,否则提示会变成噪声,用户就不再看它了`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('打分只认确定是正文的字符,拉丁扩展区乱码一律不得正分', async () => {
|
||||||
|
const { textScore, decodeTextBytes } = await mod;
|
||||||
|
const chinese = '这是一段正常的简体中文正文内容。';
|
||||||
|
const mojibake = Buffer.from(chinese, 'utf8').toString('latin1');
|
||||||
|
assert.ok(textScore(chinese) > 0, 'ASCII 与中文正文必须是正分');
|
||||||
|
assert.ok(textScore('Plain ASCII sentence.') > 0, 'ASCII 正文必须是正分');
|
||||||
|
|
||||||
|
// 乱码必须 <= 0 而不只是「低于正确解码」。一个汉字按 cp1252 摊成三个拉丁扩展字符,
|
||||||
|
// 只要这些字符拿到任何正分,乱码就靠字符数优势翻盘:实测把 0xa0..0x24f 记 1 分后,
|
||||||
|
// 同一段 GBK 正文的 windows-1252 得分从 0 涨到 320,与 gb18030 打平并靠顺序取胜。
|
||||||
|
for (const [name, text] of [
|
||||||
|
['UTF-8 中文按 cp1252 解出的乱码', mojibake],
|
||||||
|
['纯拉丁扩展区噪声', new TextDecoder('windows-1252')
|
||||||
|
.decode(new Uint8Array(Array.from({ length: 300 }, (_, i) => 0xa0 + (i % 0x40))))]
|
||||||
|
]) {
|
||||||
|
assert.ok(textScore(text) <= 0,
|
||||||
|
`${name} 得分为 ${textScore(text)},必须 <= 0,否则乱码会冒充识别成功`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gbk = new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4, 0x0a]);
|
||||||
|
assert.strictEqual(decodeTextBytes(gbk).encoding, 'gb18030',
|
||||||
|
'简体中文是本应用主场,不能被 windows-1252 的乱码抢走');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空文件与超限文件都有确定行为', async () => {
|
||||||
|
const { decodeTextBytes, MAX_TEXT_BYTES } = await mod;
|
||||||
|
const empty = decodeTextBytes(new Uint8Array(0));
|
||||||
|
assert.strictEqual(empty.text, '');
|
||||||
|
assert.strictEqual(empty.confident, true);
|
||||||
|
assert.throws(
|
||||||
|
() => decodeTextBytes(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||||
|
/超过 64 MB/,
|
||||||
|
'超限必须抛中文错误,界面直接展示这句话'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CR LF 归一,C0 控制符被剔除', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
const out = decodeTextBytes(u8('第一行\r\n第二行\r第三行\u0000\u0007\n'));
|
||||||
|
assert.strictEqual(out.text, '第一行\n第二行\n第三行\n',
|
||||||
|
'C0 控制符留在正文会让生成的 XHTML 解析失败,正文随即退回容错解析');
|
||||||
|
const tabbed = decodeTextBytes(u8('列一\t列二\n'));
|
||||||
|
assert.strictEqual(tabbed.text, '列一\t列二\n', 'Tab 是合法 XML 字符,不能顺手删掉');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- 章节切分 --- */
|
||||||
|
|
||||||
|
test('TXT 按中文章节标题切分,边界落在标题行首', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const source = novel(6);
|
||||||
|
const out = splitPlainText(source);
|
||||||
|
assert.strictEqual(out.chapters.length, 6, '6 个「第N章」应切成 6 章');
|
||||||
|
assert.deepStrictEqual(out.chapters.map((c) => c.label),
|
||||||
|
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6'],
|
||||||
|
'章节名必须取自正文里的标题行');
|
||||||
|
assert.strictEqual(out.chapters[0].start, 0);
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source,
|
||||||
|
'各章拼回来必须与原文逐字相同,否则全文一定缺字或重字');
|
||||||
|
for (const chapter of out.chapters) {
|
||||||
|
assert.strictEqual(source.slice(chapter.start, chapter.end), chapter.text, '偏移必须与切片一致');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('第一个章节标题之前的正文单独成章,不能被丢掉', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const preamble = '书名:某本小说\n作者:某人\n版权声明若干。\n\n';
|
||||||
|
const source = preamble + novel(4);
|
||||||
|
const out = splitPlainText(source);
|
||||||
|
assert.strictEqual(out.chapters.length, 5, '开头 + 4 章 = 5 章');
|
||||||
|
assert.strictEqual(out.chapters[0].label, '开头');
|
||||||
|
assert.strictEqual(out.chapters[0].text, preamble,
|
||||||
|
'首个标题之前的内容必须完整保留,否则序言与版权页在全文里凭空消失');
|
||||||
|
assert.strictEqual(out.chapters[1].label, '第1章 标题1');
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('标题不足三个时不认,按长度分节,序号从 1 开始', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const source = `第1章 只有一个标题\n${'一二三四五六七八九十'.repeat(200)}\n`.repeat(1);
|
||||||
|
const out = splitPlainText(source, { target: 500 });
|
||||||
|
assert.ok(out.chapters.length >= 1);
|
||||||
|
assert.match(out.chapters[0].label, /^第 1 节$/, '未识别到章节结构时用「第 N 节」');
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空文件、纯空白、单行超长都不崩且能拼回原文', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const empty = splitPlainText('');
|
||||||
|
assert.strictEqual(empty.chapters.length, 1);
|
||||||
|
assert.strictEqual(empty.chapters[0].text, '');
|
||||||
|
assert.strictEqual(empty.chapters[0].label, '正文');
|
||||||
|
|
||||||
|
const blank = splitPlainText(' \n\n\t \n');
|
||||||
|
assert.ok(blank.chapters.length >= 1);
|
||||||
|
assert.strictEqual(blank.chapters.map((c) => c.text).join(''), ' \n\n\t \n');
|
||||||
|
|
||||||
|
const huge = 'x'.repeat(200000);
|
||||||
|
const one = splitPlainText(huge, { target: 1000, max: 5000 });
|
||||||
|
assert.ok(one.chapters.length > 1, '没有换行的超长文本也必须切开,否则单章会撑爆 DOM');
|
||||||
|
assert.strictEqual(one.chapters.map((c) => c.text).join(''), huge);
|
||||||
|
assert.ok(one.chapters.every((c) => c.text.length <= 5000), '每章不得超过上限');
|
||||||
|
assert.ok(one.chapters.slice(1).every((c) => /(续)$/.test(c.label)), '硬切出来的后续片段要标注(续)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('章节数不超过上限,避免打出上万个 zip 条目', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const source = novel(400);
|
||||||
|
const out = splitPlainText(source, { maxChapters: 50 });
|
||||||
|
assert.ok(out.chapters.length <= 50, `实际 ${out.chapters.length} 章,超过 maxChapters`);
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source, '合并章节不能丢正文');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- Markdown 结构 --- */
|
||||||
|
|
||||||
|
test('Markdown 标题生成正确的目录层级', async () => {
|
||||||
|
const { createMarkdownRenderer, splitMarkdown, buildTocEntries, markdownHeadings } = await mod;
|
||||||
|
const md = createMarkdownRenderer(win.markdownit);
|
||||||
|
const source = [
|
||||||
|
'# 总标题', '', '开场白。', '',
|
||||||
|
'## 第一节', '', '内容一。', '',
|
||||||
|
'### 一点一', '', '内容二。', '',
|
||||||
|
'## 第二节', '', '内容三。', '',
|
||||||
|
'```', '# 代码块里的井号不是标题', '```', ''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const headings = markdownHeadings(source, md);
|
||||||
|
assert.deepStrictEqual(headings.map((h) => [h.level, h.label]), [
|
||||||
|
[1, '总标题'], [2, '第一节'], [3, '一点一'], [2, '第二节']
|
||||||
|
], '代码块里的 # 不能被当成标题');
|
||||||
|
|
||||||
|
const split = splitMarkdown(source, md);
|
||||||
|
assert.deepStrictEqual(split.chapters.map((c) => c.label),
|
||||||
|
['总标题', '第一节', '第二节'], '默认按 h1/h2 切章,h3 留在章内');
|
||||||
|
assert.strictEqual(split.title, '总标题');
|
||||||
|
assert.strictEqual(split.chapters.map((c) => c.text).join(''), source);
|
||||||
|
|
||||||
|
const { entries, anchorsByChapter } = buildTocEntries(split.chapters, split.headings, split.splitLevel);
|
||||||
|
assert.deepStrictEqual(entries.map((e) => [e.label, e.depth, e.chapter]), [
|
||||||
|
['总标题', 0, 0], ['第一节', 1, 1], ['一点一', 2, 1], ['第二节', 1, 2]
|
||||||
|
], 'depth 必须等于标题级别减一,目录才能正确缩进');
|
||||||
|
assert.strictEqual(anchorsByChapter.get(1)[0], '', '章首标题跳章即可,不需要锚点');
|
||||||
|
assert.ok(anchorsByChapter.get(1)[1], '章内标题必须有锚点,否则目录只能跳到章首');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('锚点数量与渲染出的标题数不符时不打 id,避免锚点指到错误标题', async () => {
|
||||||
|
const { createMarkdownRenderer, markdownChapterXhtml, resolveServices } = await mod;
|
||||||
|
const md = createMarkdownRenderer(win.markdownit);
|
||||||
|
const services = resolveServices();
|
||||||
|
const chapter = { label: '甲', text: '# 一\n\n正文\n\n## 二\n', start: 0, end: 20 };
|
||||||
|
|
||||||
|
const mismatched = markdownChapterXhtml(chapter, services, md, ['only-one']);
|
||||||
|
assert.ok(!/\bid="/.test(mismatched),
|
||||||
|
'章节边界可能落进代码围栏,使章内真实标题数与预估不符;此时按序号打 id 会让目录跳到错误的标题,宁可放弃锚点');
|
||||||
|
assert.strictEqual(parseXhtml(mismatched).querySelectorAll('h1,h2').length, 2, '放弃锚点不等于放弃正文');
|
||||||
|
|
||||||
|
const matched = markdownChapterXhtml(chapter, services, md, ['a1', 'a2']);
|
||||||
|
const ids = Array.from(parseXhtml(matched).querySelectorAll('[id]')).map((el) => el.getAttribute('id'));
|
||||||
|
assert.deepStrictEqual(ids, ['a1', 'a2'], '数量对得上时必须按文档顺序打 id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有 h1/h2 的 Markdown 回退到按长度分节,标题列表仍然可用', async () => {
|
||||||
|
const { createMarkdownRenderer, splitMarkdown } = await mod;
|
||||||
|
const md = createMarkdownRenderer(win.markdownit);
|
||||||
|
const source = `### 只有三级标题\n\n${'正文内容。'.repeat(50)}\n`;
|
||||||
|
const out = splitMarkdown(source, md);
|
||||||
|
assert.ok(out.chapters.length >= 1, '无 h1/h2 也要有章节');
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||||
|
assert.strictEqual(out.headings.length, 1, 'h3 仍应出现在目录里');
|
||||||
|
assert.strictEqual(out.title, '只有三级标题');
|
||||||
|
|
||||||
|
const bare = splitMarkdown('只有一行正文,没有任何标题。', md);
|
||||||
|
assert.strictEqual(bare.chapters.length, 1);
|
||||||
|
assert.ok(bare.chapters[0].label, '无标题时也要有可展示的章节名');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目录嵌套标记闭合正确,epub 目录解析器按 li > ol 递归', async () => {
|
||||||
|
const { navMarkup } = await mod;
|
||||||
|
const html = navMarkup([
|
||||||
|
{ label: 'A', depth: 0, href: 'a' },
|
||||||
|
{ label: 'A1', depth: 1, href: 'a1' },
|
||||||
|
{ label: 'A2', depth: 1, href: 'a2' },
|
||||||
|
{ label: 'B', depth: 0, href: 'b' }
|
||||||
|
]);
|
||||||
|
assert.strictEqual(html,
|
||||||
|
'<ol><li><a href="a">A</a><ol><li><a href="a1">A1</a></li><li><a href="a2">A2</a></li></ol></li><li><a href="b">B</a></li></ol>');
|
||||||
|
const doc = new win.DOMParser().parseFromString(`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||||
|
assert.ok(!doc.querySelector('parsererror'), '目录标记必须是合法 XHTML');
|
||||||
|
assert.strictEqual(doc.querySelectorAll('li').length, 4);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('li > ol > li').length, 2, '子级必须挂在父级 li 里');
|
||||||
|
|
||||||
|
const skipped = navMarkup([{ label: 'X', depth: 3, href: 'x' }]);
|
||||||
|
assert.strictEqual(skipped, '<ol><li><a href="x">X</a></li></ol>', '首条深度跳跃要收敛到顶层,不能生成孤立 ol');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('任意 depth 序列生成的目录都是闭合的合法 XHTML,且条目一个不少', async () => {
|
||||||
|
const { navMarkup } = await mod;
|
||||||
|
const cases = [
|
||||||
|
[0], [1], [0, 1, 2, 3, 2, 1, 0], [2, 0, 2], [0, 0, 0],
|
||||||
|
[0, 2, 1, 3, 0], [1, 1, 0, 5, 0], [3, 3, 3], []
|
||||||
|
];
|
||||||
|
for (const depths of cases) {
|
||||||
|
const entries = depths.map((depth, i) => ({ label: `L${i}`, depth, href: `h${i}` }));
|
||||||
|
const html = navMarkup(entries);
|
||||||
|
const doc = new win.DOMParser().parseFromString(
|
||||||
|
`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||||
|
assert.ok(!doc.querySelector('parsererror'),
|
||||||
|
`depth 序列 [${depths}] 生成了非法 XHTML:${html};标记不闭合会让整个 nav.xhtml 解析失败,目录直接退化成按章编号`);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('li').length, depths.length,
|
||||||
|
`depth 序列 [${depths}] 的目录条目数应为 ${depths.length}`);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('ol > li').length, depths.length,
|
||||||
|
`depth 序列 [${depths}] 里每个 li 都必须直接挂在 ol 下`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- 净化 --- */
|
||||||
|
|
||||||
|
const EVIL_MARKDOWN = [
|
||||||
|
'# 标题',
|
||||||
|
'',
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
'',
|
||||||
|
'<img src=x onerror=alert(1)>',
|
||||||
|
'',
|
||||||
|
'[链接](javascript:alert(1))',
|
||||||
|
'',
|
||||||
|
'<iframe src="http://evil.example"></iframe>',
|
||||||
|
'',
|
||||||
|
'<div onmouseover="alert(1)">悬停</div>',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'[正常外链](https://example.com/page)',
|
||||||
|
'',
|
||||||
|
'<svg><use xlink:href="http://evil.example/x#a" /></svg>',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
test('sanitizeMarkdownFragment 直接吃裸 HTML 也必须净化干净', async () => {
|
||||||
|
const { sanitizeMarkdownFragment, resolveServices } = await mod;
|
||||||
|
const services = resolveServices();
|
||||||
|
const fragment = sanitizeMarkdownFragment([
|
||||||
|
'<h1 id="x">标题</h1>',
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
'<img src=x onerror=alert(1)>',
|
||||||
|
'<iframe src="http://evil.example"></iframe>',
|
||||||
|
'<a href="javascript:alert(1)">链接</a>',
|
||||||
|
'<p style="background:url(http://evil.example/x)">样式</p>',
|
||||||
|
'<object data="x"></object><embed src="x" /><form action="x"></form>',
|
||||||
|
'<p onclick="alert(1)">点我</p>'
|
||||||
|
].join(''), services);
|
||||||
|
const host = win.document.createElement('div');
|
||||||
|
host.appendChild(fragment);
|
||||||
|
const doc = parseXhtml(`<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head><body>${new win.XMLSerializer().serializeToString(host)}</body></html>`);
|
||||||
|
auditUntrusted(doc, '净化后的片段');
|
||||||
|
assert.ok(doc.body.textContent.includes('标题'), '净化不能把正常正文一起吃掉');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('恶意 Markdown 渲染出的章节里没有可执行内容', async () => {
|
||||||
|
const { buildTextEpub } = await mod;
|
||||||
|
const built = await buildTextEpub(u8(EVIL_MARKDOWN), { format: 'md' });
|
||||||
|
const xhtml = await readChapter(built.bytes, 0);
|
||||||
|
const doc = parseXhtml(xhtml);
|
||||||
|
auditUntrusted(doc, 'Markdown 章节');
|
||||||
|
|
||||||
|
const text = doc.body.textContent;
|
||||||
|
assert.ok(text.includes('alert(1)'),
|
||||||
|
'危险内容应被转义成可见文字而不是静默删除,用户才知道原文写了什么');
|
||||||
|
assert.ok(text.includes('图片:图注'), '图片必须换成占位符,绝不外链加载');
|
||||||
|
assert.ok(text.includes('正常外链'), '外链文字要保留,只是不再可点');
|
||||||
|
assert.ok(!/<script|<iframe|<img|<svg/i.test(xhtml), '序列化结果里不允许出现这些标签');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死两道防线:markdown-it html:false 与放行名单里没有 href/src', async () => {
|
||||||
|
const src = fs.readFileSync(SRC, 'utf8');
|
||||||
|
assert.match(src, /markdownit\(\{[\s\S]{0,400}?html: false/,
|
||||||
|
'html:false 是第一道防线,去掉后裸 HTML 会直接进入渲染管线');
|
||||||
|
const allowed = src.match(/const ALLOWED_ATTR = Object\.freeze\(\[([^\]]*)\]\)/);
|
||||||
|
assert.ok(allowed, '必须显式声明放行属性名单');
|
||||||
|
assert.ok(!/href|src|style|srcset|on[a-z]/i.test(allowed[1]),
|
||||||
|
`放行名单不能含加载或导航类属性,当前为 ${allowed[1]}`);
|
||||||
|
assert.match(src, /RETURN_DOM_FRAGMENT: true/, '必须以片段形式取回净化结果,避免二次解析引入 mXSS');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- 端到端:契约方法 --- */
|
||||||
|
|
||||||
|
test('TXT 走完整管线:章节数、目录、locator kind 与全文', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const source = novel(6);
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
const progress = [];
|
||||||
|
try {
|
||||||
|
const info = await adapter.load(u8(source), { onProgress: (p) => progress.push(p) });
|
||||||
|
assert.strictEqual(info.format, 'txt');
|
||||||
|
assert.strictEqual(info.mode, 'plain');
|
||||||
|
assert.strictEqual(info.encoding, 'utf-8');
|
||||||
|
assert.strictEqual(info.chapterCount, 6);
|
||||||
|
assert.strictEqual(info.title, '未命名文本',
|
||||||
|
'首行本身是章节标题时不能拿来当书名,否则书库标题会变成「第1章」');
|
||||||
|
assert.ok(progress.length && progress[progress.length - 1] === 1, 'onProgress 必须走到 1');
|
||||||
|
assert.ok(progress.every((p) => p >= 0 && p <= 1), '进度必须落在 0..1');
|
||||||
|
|
||||||
|
const toc = await adapter.toc();
|
||||||
|
assert.strictEqual(toc.length, 6);
|
||||||
|
assert.deepStrictEqual(toc.map((t) => t.label),
|
||||||
|
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6']);
|
||||||
|
assert.deepStrictEqual(toc.map((t) => t.locator.chapter), [0, 1, 2, 3, 4, 5]);
|
||||||
|
for (const entry of toc) {
|
||||||
|
assert.strictEqual(entry.locator.kind, 'txt', 'locator.kind 必须是 txt,外壳靠它判断书签与笔记');
|
||||||
|
assert.strictEqual(entry.depth, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = adapter.locatorLabel({ kind: 'txt', chapter: 2, offset: 0 });
|
||||||
|
assert.strictEqual(label, '第3章 标题3');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首行是书名时取作标题,是章节标题时不取', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
assert.strictEqual(splitPlainText(`某本小说的书名\n\n${novel(4)}`).title, '某本小说的书名');
|
||||||
|
assert.strictEqual(splitPlainText(novel(4)).title, '',
|
||||||
|
'首行是「第1章」时留空,交由上层用文件名兜底');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("textOf(locator, 'document') 返回全文,不缺不重", async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const source = novel(8);
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(source));
|
||||||
|
const full = await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||||
|
assert.strictEqual(full, tidy(source),
|
||||||
|
'AI 的「全文」范围完全依赖这条,少一个字就是静默数据丢失');
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
const token = `【标记${String(i).padStart(4, '0')}】`;
|
||||||
|
assert.strictEqual(full.split(token).length - 1, 1, `${token} 必须恰好出现一次`);
|
||||||
|
}
|
||||||
|
const positions = [];
|
||||||
|
for (let i = 1; i <= 8; i++) positions.push(full.indexOf(`【标记${String(i).padStart(4, '0')}】`));
|
||||||
|
assert.deepStrictEqual(positions, [...positions].sort((a, b) => a - b), '全文顺序必须与原文一致');
|
||||||
|
|
||||||
|
const one = await adapter.textOf({ kind: 'txt', chapter: 3, offset: 0 }, 'chapter');
|
||||||
|
assert.ok(one.includes('【标记0004】'), '按章取文必须取到对应章');
|
||||||
|
assert.ok(!one.includes('【标记0005】'), '按章取文不得越界');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Markdown 走完整管线:标题渲染成 h1/h2,代码块与表格保留', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const source = [
|
||||||
|
'# 文档标题', '', '开场白。', '',
|
||||||
|
'## 列表与代码', '',
|
||||||
|
'- 第一项', '- 第二项', '',
|
||||||
|
'```js', 'const x = 1;', '```', '',
|
||||||
|
'> 引用一句话', '',
|
||||||
|
'| 列一 | 列二 |', '| --- | --- |', '| 1 | 2 |', '',
|
||||||
|
'**加粗**与 `行内代码`。', ''
|
||||||
|
].join('\n');
|
||||||
|
const adapter = createTextAdapter('md');
|
||||||
|
try {
|
||||||
|
const info = await adapter.load(u8(source));
|
||||||
|
assert.strictEqual(info.format, 'md');
|
||||||
|
assert.strictEqual(info.mode, 'markdown');
|
||||||
|
assert.strictEqual(info.title, '文档标题');
|
||||||
|
assert.strictEqual(info.chapterCount, 2);
|
||||||
|
|
||||||
|
const toc = await adapter.toc();
|
||||||
|
assert.deepStrictEqual(toc.map((t) => [t.label, t.depth]), [['文档标题', 0], ['列表与代码', 1]]);
|
||||||
|
assert.strictEqual(toc[0].locator.kind, 'md', 'Markdown 的 locator.kind 必须是 md');
|
||||||
|
|
||||||
|
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||||
|
for (const piece of ['文档标题', '开场白。', '第一项', '第二项', 'const x = 1;', '引用一句话', '列一', '加粗', '行内代码']) {
|
||||||
|
assert.ok(full.includes(piece), `全文里必须有「${piece}」,Markdown 渲染不能吞内容`);
|
||||||
|
}
|
||||||
|
assert.ok(!full.includes('```'), 'Markdown 记号应被渲染掉而不是原样留在正文里');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('渲染后的块级元素在全文里彼此分行,AI 不会拿到糊成一团的文本', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('md');
|
||||||
|
try {
|
||||||
|
// 标题与紧随其后的正文之间原文没有空行,最容易被拼成「标题甲正文乙」
|
||||||
|
await adapter.load(u8('# 标题甲\n正文乙\n\n## 标题丙\n正文丁\n\n- 列表戊\n- 列表己\n'));
|
||||||
|
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||||
|
for (const [a, b] of [['标题甲', '正文乙'], ['标题丙', '正文丁'], ['列表戊', '列表己']]) {
|
||||||
|
assert.ok(!full.includes(a + b),
|
||||||
|
`「${a}」与「${b}」被拼成了一个词,块级元素之间必须留分隔符,否则送给模型的全文语义错乱`);
|
||||||
|
assert.ok(new RegExp(`${a}\\n+${b}`).test(full), `「${a}」与「${b}」之间应有换行`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Markdown 章节渲染成真正的块级结构,不是纯文本', async () => {
|
||||||
|
const { buildTextEpub } = await mod;
|
||||||
|
const built = await buildTextEpub(u8([
|
||||||
|
'# 标题', '', '- 项', '', '```js', 'const x = 1;', '```', '',
|
||||||
|
'> 引用', '', '| a | b |', '| - | - |', '| 1 | 2 |', ''
|
||||||
|
].join('\n')), { format: 'md' });
|
||||||
|
const doc = parseXhtml(await readChapter(built.bytes, 0));
|
||||||
|
assert.strictEqual(doc.querySelectorAll('h1').length, 1, 'Markdown 标题必须渲染成 h1');
|
||||||
|
assert.strictEqual(doc.querySelectorAll('ul > li').length, 1);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('pre > code').length, 1);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('blockquote').length, 1);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('table td').length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超大 Markdown 降级为纯文本,不让 markdown-it 整篇解析', async () => {
|
||||||
|
const { buildTextEpub, MARKDOWN_MAX_CHARS } = await mod;
|
||||||
|
const over = `# 标题\n\n${'abcde\n'.repeat(Math.ceil(MARKDOWN_MAX_CHARS / 6) + 100)}`;
|
||||||
|
assert.ok(over.length > MARKDOWN_MAX_CHARS);
|
||||||
|
const built = await buildTextEpub(u8(over), { format: 'md' });
|
||||||
|
assert.strictEqual(built.mode, 'plain', '超限时必须降级,否则整篇解析的开销与内存都不可控');
|
||||||
|
const under = await buildTextEpub(u8('# 标题\n\n正文。\n'), { format: 'md' });
|
||||||
|
assert.strictEqual(under.mode, 'markdown', '正常体量的 md 必须走渲染路径');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('几十 MB 的 txt 切成小章,单章体积可控', async () => {
|
||||||
|
const { buildTextEpub, splitPlainText } = await mod;
|
||||||
|
const block = `第N章 标题\n${'这一段是压力测试用的中文正文。'.repeat(10)}\n\n`;
|
||||||
|
const source = block.repeat(2000).replace(/第N章/g, () => '第1章');
|
||||||
|
const split = splitPlainText(source);
|
||||||
|
assert.ok(split.chapters.length <= 4000, '章节数必须有上限,否则 zip 条目数失控');
|
||||||
|
assert.ok(split.chapters.every((c) => c.text.length <= 48000),
|
||||||
|
'单章必须足够小,一次性把整份文本塞进 DOM 会卡死渲染进程');
|
||||||
|
const built = await buildTextEpub(u8(source), { format: 'txt' });
|
||||||
|
assert.strictEqual(built.chapterCount, split.chapters.length);
|
||||||
|
assert.strictEqual(built.mode, 'plain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextLocator / prevLocator 在两端返回 null', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(novel(4)));
|
||||||
|
assert.strictEqual(adapter.prevLocator({ kind: 'txt', chapter: 0, offset: 0 }), null,
|
||||||
|
'第一章没有上一章,返回 null 外壳才会禁用按钮');
|
||||||
|
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 3, offset: 0 }), null,
|
||||||
|
'最后一章没有下一章');
|
||||||
|
const next = adapter.nextLocator({ kind: 'txt', chapter: 0, offset: 500 });
|
||||||
|
assert.deepStrictEqual(next, { kind: 'txt', chapter: 1, offset: 0 });
|
||||||
|
const prev = adapter.prevLocator({ kind: 'txt', chapter: 2, offset: 10 });
|
||||||
|
assert.deepStrictEqual(prev, { kind: 'txt', chapter: 1, offset: 0 });
|
||||||
|
assert.deepStrictEqual(adapter.nextLocator(null), { kind: 'txt', chapter: 1, offset: 0 },
|
||||||
|
'非法 locator 要按第 0 章处理,不能抛');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('percentOf 与 locatorFromPercent 互为逆运算', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(novel(8)));
|
||||||
|
// 章内百分比需要章节长度,先取一次全文把长度缓存起来(外壳打开书后同样会发生)
|
||||||
|
await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||||
|
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 0, offset: 0 }), 0);
|
||||||
|
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 7, offset: 1e9 }), 1);
|
||||||
|
for (const p of [0, 0.125, 0.3, 0.5, 0.77, 1]) {
|
||||||
|
const locator = adapter.locatorFromPercent(p);
|
||||||
|
assert.strictEqual(locator.kind, 'txt');
|
||||||
|
const back = adapter.percentOf(locator);
|
||||||
|
assert.ok(Math.abs(back - p) < 0.02,
|
||||||
|
`百分比往返偏差过大:${p} -> ${JSON.stringify(locator)} -> ${back};进度条与书签会漂移`);
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(adapter.locatorFromPercent(-5), { kind: 'txt', chapter: 0, offset: 0 },
|
||||||
|
'越界百分比必须夹紧');
|
||||||
|
assert.strictEqual(adapter.locatorFromPercent(99).chapter, 7);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超过体积上限时 load 抛中文错误', async () => {
|
||||||
|
const { createTextAdapter, MAX_TEXT_BYTES } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => adapter.load(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||||
|
/超过 64 MB/,
|
||||||
|
'超限错误会原样显示给用户,必须是中文'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('适配器暴露外壳依赖的全部方法,且 .md 后缀归一', async () => {
|
||||||
|
const { createTextAdapter, normalizeTextFormat } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
for (const name of [
|
||||||
|
'load', 'renderTo', 'toc', 'getSelection', 'textOf', 'locatorLabel',
|
||||||
|
'nextLocator', 'prevLocator', 'percentOf', 'locatorFromPercent',
|
||||||
|
'capturePinchAnchor', 'restorePinchAnchor', 'setLocatorChangeHandler',
|
||||||
|
'setTouchGestureHandler', 'visualViewportRect', 'destroy'
|
||||||
|
]) {
|
||||||
|
assert.strictEqual(typeof adapter[name], 'function', `外壳会调用 ${name},必须实现`);
|
||||||
|
}
|
||||||
|
assert.strictEqual(adapter.getSelection(), null, '未渲染时取选区应返回 null 而不是抛错');
|
||||||
|
assert.strictEqual(adapter.visualViewportRect(), null);
|
||||||
|
assert.strictEqual(adapter.capturePinchAnchor(0, 0), null);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
assert.strictEqual(normalizeTextFormat('.MD'), 'md');
|
||||||
|
assert.strictEqual(normalizeTextFormat('markdown'), 'md');
|
||||||
|
assert.strictEqual(normalizeTextFormat('txt'), 'txt');
|
||||||
|
assert.strictEqual(normalizeTextFormat(undefined), 'txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 之后再次 load 不残留上一本书的状态', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(novel(6)));
|
||||||
|
assert.strictEqual((await adapter.toc()).length, 6);
|
||||||
|
adapter.destroy();
|
||||||
|
assert.strictEqual(adapter.documentInfo(), null, 'destroy 必须清掉文档信息');
|
||||||
|
await adapter.load(u8(novel(3)));
|
||||||
|
assert.strictEqual((await adapter.toc()).length, 3, '重新 load 后目录必须是新书的');
|
||||||
|
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 2, offset: 0 }), null);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,987 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const utilFile = path.join(__dirname, '..', 'ui', 'util.js');
|
||||||
|
const libFile = path.join(__dirname, '..', 'ui', 'views', 'library.js');
|
||||||
|
const utilSrc = fs.readFileSync(utilFile, 'utf8');
|
||||||
|
|
||||||
|
// util.js 只做 window.X = ... 赋值,没有加载期副作用,
|
||||||
|
// 因此可以整体求值拿到真实实现(DOM 依赖都在调用时才触发)。
|
||||||
|
function loadUtil() {
|
||||||
|
const win = {};
|
||||||
|
const store = new Map();
|
||||||
|
win.localStorage = {
|
||||||
|
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||||
|
setItem: (k, v) => store.set(k, String(v))
|
||||||
|
};
|
||||||
|
new Function('window', 'localStorage', 'document', utilSrc)(win, win.localStorage, undefined);
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
// style="..." 里的值会先被 HTML 解码,再交给 CSS 解析
|
||||||
|
function htmlDecode(s) {
|
||||||
|
return s.replace(/'/g, "'").replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('escapeHtml 覆盖全部危险字符', () => {
|
||||||
|
const { escapeHtml } = loadUtil();
|
||||||
|
assert.strictEqual(escapeHtml(`<a href="x">&'`), '<a href="x">&'');
|
||||||
|
assert.strictEqual(escapeHtml(null), '');
|
||||||
|
assert.strictEqual(escapeHtml(undefined), '');
|
||||||
|
assert.strictEqual(escapeHtml(0), '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverStyle 阻断 style 属性逃逸', () => {
|
||||||
|
const { coverStyle } = loadUtil();
|
||||||
|
const out = coverStyle('https://evil/a.jpg") onerror="alert(1)');
|
||||||
|
assert.ok(!out.includes('"'), '裸引号泄漏: ' + out);
|
||||||
|
assert.ok(out.includes('"'), '未做 HTML 转义: ' + out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverStyle 阻断 CSS 串逃逸', () => {
|
||||||
|
const { coverStyle } = loadUtil();
|
||||||
|
// 浏览器会先 HTML 解码属性值,再按 CSS 解析,这里模拟同样的两步
|
||||||
|
const css = htmlDecode(coverStyle("https://evil/a.jpg'); background:url('x"));
|
||||||
|
const inner = css.replace(/^background-image:url\('/, '').replace(/'\)$/, '');
|
||||||
|
assert.ok(!/(^|[^\\])'/.test(inner), 'CSS 单引号未转义,可提前闭合 url(): ' + css);
|
||||||
|
assert.ok(!/(^|[^\\])\)/.test(inner), 'CSS 右括号未转义: ' + css);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverStyle 拒绝换行注入', () => {
|
||||||
|
const { coverStyle } = loadUtil();
|
||||||
|
assert.strictEqual(coverStyle('https://x/a.jpg\n background:red'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverStyle 正常输入仍可用', () => {
|
||||||
|
const { coverStyle } = loadUtil();
|
||||||
|
assert.strictEqual(coverStyle(''), '');
|
||||||
|
// 转义后浏览器实际解析到的地址才是关注点
|
||||||
|
const remote = htmlDecode(coverStyle('https://x/a.jpg')).replace(/\\(.)/g, '$1');
|
||||||
|
assert.strictEqual(remote, "background-image:url('https://x/a.jpg')");
|
||||||
|
const local = htmlDecode(coverStyle('C:\\books\\c.jpg')).replace(/\\([('")])/g, '$1');
|
||||||
|
assert.ok(local.includes('file:///C:/books/c.jpg'), local);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDate 补零', () => {
|
||||||
|
const { formatDate } = loadUtil();
|
||||||
|
assert.strictEqual(formatDate(0), '');
|
||||||
|
assert.strictEqual(formatDate(new Date(2024, 0, 5).getTime()), '2024-01-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabledSources 存取;损坏数据回退为 null', () => {
|
||||||
|
const win = loadUtil();
|
||||||
|
assert.strictEqual(win.getEnabledSources(), null);
|
||||||
|
win.setEnabledSources(['arxiv', 'pmc']);
|
||||||
|
assert.deepStrictEqual(win.getEnabledSources(), ['arxiv', 'pmc']);
|
||||||
|
win.localStorage.setItem('enabledSources', '{坏json');
|
||||||
|
assert.strictEqual(win.getEnabledSources(), null, '损坏数据应回退而不是抛错');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('添加本地内容支持文件、文件夹和上级目录分类选项', () => {
|
||||||
|
const src = fs.readFileSync(libFile, 'utf8');
|
||||||
|
assert.match(src, /name="localImportSource" value="files"/);
|
||||||
|
assert.match(src, /name="localImportSource" value="folder"/);
|
||||||
|
assert.match(src, /window\.api\.library\.pickLocal\(source\)/);
|
||||||
|
assert.match(src, /value="shelf"[\s\S]+上一级目录作为书架/);
|
||||||
|
assert.match(src, /value="tag"[\s\S]+上一级目录作为标签/);
|
||||||
|
assert.match(src, /window\.api\.library\.importLocal\(selection\.selectionId,\s*options\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('写进 HTML 的字段插值都过 escapeHtml', () => {
|
||||||
|
for (const f of ['views/browse.js', 'views/library.js']) {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', f), 'utf8');
|
||||||
|
// 只看真正拼 HTML 的行(含标签),DOM 选择器之类的插值不在此列
|
||||||
|
const bad = [];
|
||||||
|
src.split('\n').forEach((line, i) => {
|
||||||
|
if (!/<[a-z]/i.test(line)) return;
|
||||||
|
for (const m of line.match(/\$\{(?!escapeHtml|coverStyle)[^}]*\}/g) || []) {
|
||||||
|
if (/^\$\{(it|d|f|l|s|e|b)\.[a-zA-Z_]+\}$/.test(m)) bad.push(`${f}:${i + 1} ${m}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(bad, [], `存在未转义的 HTML 插值:\n${bad.join('\n')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index.html 保留 CSP 且未开启 nodeIntegration', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
assert.ok(/Content-Security-Policy/.test(html), '缺少 CSP');
|
||||||
|
assert.ok(/default-src 'self'/.test(html));
|
||||||
|
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||||
|
assert.ok(/contextIsolation:\s*true/.test(main));
|
||||||
|
assert.ok(/nodeIntegration:\s*false/.test(main));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('我的笔记页可新建关联或无关联笔记', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
|
||||||
|
assert.match(html, /data-tab="notes">我的笔记</);
|
||||||
|
assert.match(html, /id="addGlobalNoteBtn"/);
|
||||||
|
assert.match(notes, /window\.api\.library\.list\(\)/);
|
||||||
|
assert.match(notes, /source:\s*'manual'/);
|
||||||
|
assert.match(notes, /<option value="">不关联书籍<\/option>/);
|
||||||
|
assert.match(notes, /window\.api\.reader\.addNote\(entryId,\s*note\)/);
|
||||||
|
assert.match(notes, /window\.api\.reader\.addStandaloneNote\(note\)/);
|
||||||
|
assert.match(notes, /note\.associated === false\s*\?\s*'未关联书籍'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书库支持标题作者模糊搜索、侧栏滚动和稳定封面占位卡', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
assert.match(html, /id="librarySearchInput"[^>]+搜索标题或作者/);
|
||||||
|
assert.match(html, /id="librarySearchBtn"/);
|
||||||
|
assert.match(html, /id="libraryClearSearchBtn"/);
|
||||||
|
assert.match(html, /id="sortSelect"[\s\S]*value="recent">最近阅读/);
|
||||||
|
assert.match(library, /recent:\s*\(a,\s*b\)[\s\S]*lastReadAt/);
|
||||||
|
assert.match(library, /function matchesSearch\(item, query\)/);
|
||||||
|
assert.match(library, /item\.authors/);
|
||||||
|
assert.match(library, /isSubsequence/);
|
||||||
|
assert.match(library, /function reconcileCards/);
|
||||||
|
assert.doesNotMatch(library, /grid\.innerHTML\s*=\s*items\.map/);
|
||||||
|
const sidebarRule = css.match(/\.library-sidebar\s*\{([^}]*)\}/);
|
||||||
|
assert.ok(sidebarRule);
|
||||||
|
assert.match(sidebarRule[1], /max-height:\s*calc\(100vh - 84px\)/);
|
||||||
|
assert.match(sidebarRule[1], /overflow-y:\s*auto/);
|
||||||
|
assert.match(css, /\.card:hover \.card-cover:not\(\[data-cover-state="pending"\]\)/);
|
||||||
|
assert.match(library, /data-cover-state="\$\{it\.cover \? 'ready' : 'pending'\}"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书库页提供可管理标签目录和整理多选下拉', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
|
assert.match(html, /id="libraryShelfList"/);
|
||||||
|
assert.match(html, /id="libraryTagList"/);
|
||||||
|
assert.match(html, /id="addTagBtn"/);
|
||||||
|
assert.match(library, /window\.api\.library\.listShelves\(\)/);
|
||||||
|
assert.match(library, /window\.api\.library\.addTag\(\{ name \}\)/);
|
||||||
|
assert.match(library, /window\.api\.library\.updateTag\(tag\.id/);
|
||||||
|
assert.match(library, /window\.api\.library\.removeTag\(tag\.id\)/);
|
||||||
|
assert.match(library, /cardAction\('organize'/);
|
||||||
|
assert.match(library, /<details id="libraryBookTags"/);
|
||||||
|
assert.match(library, /#libraryBookTags input\[type="checkbox"\]:checked/);
|
||||||
|
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载区接入全局任务中心且完成按钮使用高对比绿色底色', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||||
|
const center = fs.readFileSync(path.join(__dirname, '..', 'ui', 'download-center.js'), 'utf8');
|
||||||
|
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
|
||||||
|
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
assert.match(html, /id="taskCenterBtn"/);
|
||||||
|
assert.match(html, /id="taskCenterPanel"/);
|
||||||
|
assert.ok(html.indexOf('download-center.js') < html.indexOf('views/browse.js'));
|
||||||
|
assert.match(app, /DownloadCenter\.init\(\)/);
|
||||||
|
assert.match(browse, /window\.DownloadCenter\.start\(\{/);
|
||||||
|
assert.doesNotMatch(browse, /await window\.api\.downloadFile/);
|
||||||
|
assert.match(center, /window\.api\.downloads\.run\(/);
|
||||||
|
assert.match(center, /window\.api\.downloads\.pause\(/);
|
||||||
|
assert.match(center, /window\.api\.downloads\.delete\(/);
|
||||||
|
assert.match(center, /data-task-action="pause"/);
|
||||||
|
assert.match(center, /data-task-action="resume"/);
|
||||||
|
assert.match(center, /data-task-action="delete"/);
|
||||||
|
assert.match(preload, /pause:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:pause'/);
|
||||||
|
assert.match(preload, /delete:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:delete'/);
|
||||||
|
assert.match(center, /task\.status = 'complete'/);
|
||||||
|
assert.match(center, /task\.status = 'failed'/);
|
||||||
|
assert.match(center, /data-task-action="open"/);
|
||||||
|
assert.match(css, /\.task-center-panel\s*\{/);
|
||||||
|
assert.match(css, /\.task-center-badge\s*\{/);
|
||||||
|
assert.match(browse, /createDownloadProgress/);
|
||||||
|
assert.match(browse, /updateDownloadProgress/);
|
||||||
|
assert.match(browse, /classList\.add\('downloaded'\)/);
|
||||||
|
const rule = css.match(/\.dl-btn\.downloaded\s*\{([^}]*)\}/);
|
||||||
|
assert.ok(rule, '缺少下载完成按钮样式');
|
||||||
|
assert.match(rule[1], /background:\s*var\(--green\)/);
|
||||||
|
assert.doesNotMatch(rule[1], /background:\s*var\(--accent\)/);
|
||||||
|
assert.match(rule[1], /color:\s*#07130b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('主窗口在设置旁提供持久化明暗主题切换', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
const themeAt = html.indexOf('id="uiThemeBtn"');
|
||||||
|
const settingsAt = html.indexOf('data-tab="settings"');
|
||||||
|
assert.ok(themeAt >= 0 && themeAt < settingsAt, '主题按钮不在设置按钮旁边');
|
||||||
|
assert.match(app, /window\.api\.ui\.getTheme\(\)/);
|
||||||
|
assert.match(app, /window\.api\.ui\.setTheme\(next\)/);
|
||||||
|
assert.match(css, /:root\[data-ui-theme="light"\]/);
|
||||||
|
assert.match(css, /--bg:\s*#f4f7fb/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('人民阅读器品牌与主题图标显示在界面左上角', () => {
|
||||||
|
for (const file of ['index.html', 'reader.html']) {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', file), 'utf8');
|
||||||
|
assert.match(html, /<title>PeopleLib<\/title>/);
|
||||||
|
assert.match(html, /人民阅读器/);
|
||||||
|
assert.match(html, /brand-logo-dark[^>]+icons\/dist\/dark\/icon-32\.png/);
|
||||||
|
assert.match(html, /brand-logo-light[^>]+icons\/dist\/light\/icon-32\.png/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('阅读器控件隔离正文选择并提供 PDF 适宽、拖拽和文本选择工具', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
|
||||||
|
const pdfWorker = fs.readFileSync(path.join(__dirname, '..', 'ui', 'vendor', 'pdf.worker.range.mjs'), 'utf8');
|
||||||
|
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
|
||||||
|
assert.match(html, /id="fitWidthBtn"[\s\S]*aria-label="适应内容宽度"/);
|
||||||
|
assert.match(html, /data-annotation-tool="pan"[\s\S]*data-annotation-tool="text-select"/);
|
||||||
|
assert.match(css, /button,[\s\S]*\.statusbar,[\s\S]*user-select:\s*none/);
|
||||||
|
assert.match(shell, /isReaderControlTarget/);
|
||||||
|
assert.match(shell, /fitPdfWidth/);
|
||||||
|
assert.match(pdf, /function fitWidthScale/);
|
||||||
|
assert.match(pdf, /className = 'endOfContent'/);
|
||||||
|
assert.match(pdf, /const endPage = pageOfNode\(range\.endContainer\)/);
|
||||||
|
assert.match(pdf, /pdfx-tool-pan/);
|
||||||
|
assert.match(pdf, /extends pdfjs\.PDFDataRangeTransport/);
|
||||||
|
assert.match(pdf, /pdf\.worker\.range\.mjs/);
|
||||||
|
assert.match(pdf, /disableAutoFetch\s*=\s*true/);
|
||||||
|
assert.match(pdfWorker, /super\(new Uint8Array\(0\), 0, length, null\)/);
|
||||||
|
assert.doesNotMatch(pdfWorker, /super\(new Uint8Array\(length\), 0, length, null\)/);
|
||||||
|
assert.match(pdfWorker, /MAX_SPARSE_PDF_CACHE_BYTES = 256 \* 1024 \* 1024/);
|
||||||
|
assert.match(pdfWorker, /MAX_GROUPED_RANGE_CHUNKS = 4/);
|
||||||
|
assert.match(pdfWorker, /offset = offset \* 256 \+ offsetByte/);
|
||||||
|
assert.match(pdfWorker, /_loadedChunks\.delete\(chunk\)/);
|
||||||
|
// 稀疏基础缓冲区是空的,字体哈希不能再直接按 stream.bytes.buffer 建视图,
|
||||||
|
// 否则字体会静默变成不可见的 ErrorFont
|
||||||
|
assert.match(pdfWorker, /stream\.getByteRange\(stream\.start, stream\.end\)/);
|
||||||
|
assert.doesNotMatch(pdfWorker, /new Uint8Array\(stream\.bytes\.buffer, stream\.start, stream\.end - stream\.start\)/);
|
||||||
|
// 256 MB 以内仍用官方 worker,只有超出才启用稀疏 worker
|
||||||
|
assert.match(pdf, /STANDARD_WORKER_MAX_BYTES/);
|
||||||
|
assert.match(pdf, /SPARSE_WORKER_URL/);
|
||||||
|
assert.match(pdf, /this\.active < 8/);
|
||||||
|
assert.match(pdf, /Promise\.race\(\[task\.promise, rangeFailurePromise\]\)/);
|
||||||
|
assert.match(shell, /openPdfRangeSource/);
|
||||||
|
assert.match(shell, /api\.reader\.rangeRead/);
|
||||||
|
assert.match(preload, /rangeOpen:[\s\S]*reader:rangeOpen/);
|
||||||
|
assert.match(preload, /rangeRead:[\s\S]*reader:rangeRead/);
|
||||||
|
assert.match(preload, /rangeClose:[\s\S]*reader:rangeClose/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 助手提供受限图像上下文和可扩展 OCR 契约', () => {
|
||||||
|
const index = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
|
||||||
|
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
|
||||||
|
const ocr = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'ocr-provider.mjs'), 'utf8');
|
||||||
|
const contract = fs.readFileSync(path.join(__dirname, '..', 'reader', 'visual-context.js'), 'utf8');
|
||||||
|
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
|
||||||
|
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||||
|
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
|
||||||
|
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||||
|
assert.match(index, /id="aiProtocol"[\s\S]*value="anthropic"[\s\S]*value="openai-responses"[\s\S]*value="chat-completions"/);
|
||||||
|
assert.match(index, /id="aiVision"[^>]*type="checkbox"/);
|
||||||
|
assert.match(app, /protocol:\s*\$\('aiProtocol'\)\.value/);
|
||||||
|
assert.match(reader, /value="page-image"[\s\S]*value="region-image"/);
|
||||||
|
assert.match(reader, /id="aiVisualCard"[\s\S]*id="aiOcrBtn"[\s\S]*disabled/);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
[...reader.matchAll(/data-ai-task="([^"]+)"/g)].map((match) => match[1]),
|
||||||
|
['summarize']
|
||||||
|
);
|
||||||
|
assert.match(shell, /function beginVisualSelection/);
|
||||||
|
assert.match(shell, /function confirmVisualSelection/);
|
||||||
|
assert.match(shell, /toAiVisualContext/);
|
||||||
|
assert.match(pdf, /async function captureVisual/);
|
||||||
|
assert.match(pdf, /function visualPageAtPoint/);
|
||||||
|
assert.match(epub, /function visualViewportRect/);
|
||||||
|
assert.match(ocr, /function registerOcrProvider/);
|
||||||
|
assert.match(ocr, /function recognizeOcr/);
|
||||||
|
assert.match(ocr, /signal:\s*options\.signal/);
|
||||||
|
assert.match(contract, /MAX_IMAGE_BYTES\s*=\s*3\s*\*\s*1024\s*\*\s*1024/);
|
||||||
|
assert.match(contract, /MAX_VISUAL_CONTEXTS\s*=\s*1/);
|
||||||
|
assert.match(contract, /图像内容与声明尺寸不匹配/);
|
||||||
|
assert.match(client, /type:\s*'image_url'/);
|
||||||
|
assert.match(client, /type:\s*'image'[\s\S]*type:\s*'base64'[\s\S]*media_type:/);
|
||||||
|
assert.match(client, /type:\s*'input_image'/);
|
||||||
|
assert.match(client, /当前模型配置未启用图像输入/);
|
||||||
|
assert.match(app, /模型已配置[\s\S]*尚缺 API Key/);
|
||||||
|
assert.match(app, /已保存的 API Key 无法读取,请重新输入/);
|
||||||
|
assert.match(shell, /模型已配置,但尚缺 API Key/);
|
||||||
|
assert.match(shell, /模型已配置,但已保存的 API Key 无法读取/);
|
||||||
|
assert.match(preload, /onChanged:\s*\(cb\)[\s\S]*ipcRenderer\.on\('ai:changed'/);
|
||||||
|
assert.match(shell, /api\.ai\.onChanged\(\(\)\s*=>\s*refreshAiStatus\(\)\)/);
|
||||||
|
assert.match(preload, /function captureReaderRect\(rect\)/);
|
||||||
|
assert.match(preload, /document\.getElementById\('docArea'\)/);
|
||||||
|
assert.match(preload, /captureRect:\s*\(rect\)\s*=>\s*captureReaderRect\(rect\)/);
|
||||||
|
assert.match(main, /ipcMain\.handle\('reader:captureRect'/);
|
||||||
|
assert.match(main, /截图区域无效或超出阅读器窗口/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 上下文提供无需选中的当前页与全文范围', () => {
|
||||||
|
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
|
||||||
|
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(reader, /<option value="document">全文<\/option>/);
|
||||||
|
assert.doesNotMatch(reader, /value="chapter"/);
|
||||||
|
|
||||||
|
// 只有 selection 需要选区,page/document 直接走 textOf
|
||||||
|
assert.match(shell, /if \(scope === 'selection'\)[\s\S]{0,400}请先在正文中选中文本/);
|
||||||
|
assert.match(shell, /scope === 'page' \? 'page' : 'document'/);
|
||||||
|
|
||||||
|
// 全文必须提示可能超限,并且始终弹确认框
|
||||||
|
assert.match(shell, /可能超过模型限制/);
|
||||||
|
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
|
||||||
|
// 正文不再本地截断,文案必须如实说明"完整发送 + 超限由接口报错",
|
||||||
|
// 否则界面显示的字数与实际外发字数不一致(实测 40 页只发出 8 页)
|
||||||
|
assert.match(shell, /全文将完整发送/);
|
||||||
|
assert.doesNotMatch(shell, /保留首尾并截断/);
|
||||||
|
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
|
||||||
|
assert.doesNotMatch(client, /let body = clipContext\(text\)/);
|
||||||
|
assert.match(client, /上下文超出模型窗口/);
|
||||||
|
|
||||||
|
// 旧设置迁移,避免升级后回落成 selection
|
||||||
|
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
|
||||||
|
assert.match(shell, /api\.settings\.set\('reader\.aiScope', 'document'\)/);
|
||||||
|
|
||||||
|
// 适配器真的取整本,而不是当前页 ±1
|
||||||
|
assert.match(pdf, /if \(span !== 'document'\) return pageText\(page\)/);
|
||||||
|
assert.match(pdf, /for \(let i = 1; i <= pageCount; i\+\+\)/);
|
||||||
|
assert.match(epub, /if \(span === 'document'\)[\s\S]{0,400}chapter < spine\.length/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 图像上下文按更小的目标体积压缩且只用 JPEG', () => {
|
||||||
|
const visual = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'visual-context.mjs'), 'utf8');
|
||||||
|
assert.match(visual, /MAX_CAPTURE_DIMENSION = 1600/);
|
||||||
|
assert.match(visual, /TARGET_CAPTURE_BYTES = 400 \* 1024/);
|
||||||
|
assert.match(visual, /const qualities = \[0\.82, 0\.74, 0\.66, 0\.58\]/);
|
||||||
|
assert.match(visual, /bytes <= TARGET_CAPTURE_BYTES/);
|
||||||
|
// 缩到 800px 就停手,避免文字页被压糊
|
||||||
|
assert.match(visual, /<= 800\) break/);
|
||||||
|
// 只保留一条 JPEG 编码路径,不做格式回退
|
||||||
|
assert.deepStrictEqual([...visual.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]), ['image/jpeg']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 回答使用固定版本 Markdown-it 和 DOMPurify 安全渲染', () => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const renderer = fs.readFileSync(path.join(__dirname, '..', 'ui', 'ai-markdown.js'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||||
|
assert.strictEqual(pkg.devDependencies['markdown-it'], '15.0.0');
|
||||||
|
assert.strictEqual(pkg.devDependencies.dompurify, '3.4.12');
|
||||||
|
assert.match(html, /vendor\/purify\.min\.js[\s\S]*vendor\/markdown-it\.min\.js[\s\S]*ai-markdown\.js/);
|
||||||
|
assert.match(renderer, /html:\s*false/);
|
||||||
|
assert.match(renderer, /purifier\.sanitize/);
|
||||||
|
assert.match(renderer, /renderer\.rules\.image/);
|
||||||
|
assert.match(renderer, /data-external-url/);
|
||||||
|
assert.match(renderer, /MAX_MARKDOWN_LENGTH\s*=\s*256\s*\*\s*1024/);
|
||||||
|
assert.match(shell, /scheduleAiOutput/);
|
||||||
|
assert.match(shell, /window\.AiMarkdown\.externalUrl/);
|
||||||
|
assert.match(shell, /addEventListener\('auxclick'/);
|
||||||
|
assert.match(css, /\.ai-output pre[\s\S]*overflow:\s*auto/);
|
||||||
|
for (const file of [
|
||||||
|
'vendor/markdown-it.min.js',
|
||||||
|
'vendor/markdown-it.LICENSE.txt',
|
||||||
|
'vendor/purify.min.js',
|
||||||
|
'vendor/DOMPurify.LICENSE.txt'
|
||||||
|
]) {
|
||||||
|
assert.ok(fs.existsSync(path.join(__dirname, '..', 'ui', file)), `${file} 未随应用提供`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('安装包和可执行文件保留 PeopleLib 产品名', () => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||||
|
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||||
|
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||||
|
assert.strictEqual(pkg.build.productName, 'PeopleLib');
|
||||||
|
assert.strictEqual(pkg.build.portable.artifactName, 'PeopleLib-${version}.exe');
|
||||||
|
assert.match(main, /app\.setName\('PeopleLib'\)/);
|
||||||
|
assert.match(build, /const PRODUCT = pkg\.productName \|\| 'PeopleLib'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('设置关于页与 README 列出书库和内置阅读格式', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
|
||||||
|
assert.match(html, /关于 PeopleLib/);
|
||||||
|
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3、TXT、MD/);
|
||||||
|
assert.match(html, /书库导入与管理[\s\S]*TXT、MD、DJVU、FB2、CBZ、CBR/);
|
||||||
|
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
|
||||||
|
assert.match(readme, /## 支持格式/);
|
||||||
|
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
|
||||||
|
assert.match(readme, /TXT \/ MD[\s\S]*Markdown/);
|
||||||
|
assert.match(readme, /DJVU \/ FB2 \/ CBZ \/ CBR/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||||
|
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.mjs'), 'utf8');
|
||||||
|
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||||
|
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
|
||||||
|
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/);
|
||||||
|
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
|
||||||
|
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
|
||||||
|
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
|
||||||
|
assert.match(adapter, /该 MOBI\/AZW 图书有 DRM 保护/);
|
||||||
|
assert.match(shell, /使用系统应用打开/);
|
||||||
|
assert.match(build, /node_modules', 'foliate-js'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书库卡片操作使用带悬浮提示的纯图标按钮', () => {
|
||||||
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
|
assert.match(library, /const CARD_ICONS =/);
|
||||||
|
assert.match(library, /class="\$\{primary \? 'open-btn ' : ''\}icon-action"/);
|
||||||
|
assert.match(library, /title="\$\{label\}" aria-label="\$\{label\}"/);
|
||||||
|
for (const action of ['read', 'open', 'reveal', 'page', 'organize', 'remove']) {
|
||||||
|
assert.match(library, new RegExp(`cardAction\\('${action}'`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版', () => {
|
||||||
|
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
|
||||||
|
const rich = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.js'), 'utf8');
|
||||||
|
const mixed = fs.readFileSync(path.join(__dirname, '..', 'ui', 'mixed-note.js'), 'utf8');
|
||||||
|
const canvas = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-note.mjs'), 'utf8');
|
||||||
|
const canvasFlow = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-flow.mjs'), 'utf8');
|
||||||
|
const richCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.css'), 'utf8');
|
||||||
|
const appCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
const readerCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||||
|
const readerHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const indexHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const store = fs.readFileSync(path.join(__dirname, '..', 'reader', 'store.js'), 'utf8');
|
||||||
|
const assets = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-assets.js'), 'utf8');
|
||||||
|
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||||
|
assert.match(notes, /id="newNoteRich"/);
|
||||||
|
assert.match(notes, /id="noteEditRich"/);
|
||||||
|
assert.match(notes, /window\.MixedNote\.mount/);
|
||||||
|
assert.match(notes, /选择笔记类型/);
|
||||||
|
assert.match(notes, /noteType/);
|
||||||
|
assert.match(readerHtml, /id="noteRichEditor"/);
|
||||||
|
assert.match(indexHtml, /vendor\/quill\/quill\.js/);
|
||||||
|
assert.match(indexHtml, /vendor\/quill\/quill\.snow\.css/);
|
||||||
|
assert.match(readerHtml, /vendor\/jspdf\.umd\.min\.js/);
|
||||||
|
assert.match(readerHtml, /<script src="rich-note\.js"><\/script>/);
|
||||||
|
assert.match(rich, /new window\.Quill/);
|
||||||
|
assert.match(rich, /version:\s*2,\s*ops/);
|
||||||
|
assert.doesNotMatch(rich, /document\.execCommand/);
|
||||||
|
assert.ok(
|
||||||
|
rich.indexOf("header.className = 'ql-header'") < rich.indexOf("['bold', '加粗']"),
|
||||||
|
'段落类型必须位于 B/I 等格式按钮之前'
|
||||||
|
);
|
||||||
|
assert.match(richCss, /\.ql-toolbar \.ql-picker-options/);
|
||||||
|
assert.match(richCss, /background:\s*var\(--bg-card\)/);
|
||||||
|
assert.match(richCss, /color:\s*var\(--text\)/);
|
||||||
|
assert.match(rich, /image\/jpeg,image\/png,image\/gif,image\/webp/);
|
||||||
|
assert.match(rich, /单张图片不能超过 2 MB/);
|
||||||
|
assert.match(mixed, /function mountTyped/);
|
||||||
|
assert.match(mixed, /options\.noteType/);
|
||||||
|
assert.match(indexHtml, /id="notesTypeTabs"/);
|
||||||
|
assert.match(indexHtml, />全部</);
|
||||||
|
assert.match(indexHtml, />画布笔记</);
|
||||||
|
assert.match(indexHtml, />读书笔记</);
|
||||||
|
assert.match(appCss, /\.notes-list[\s\S]*grid-template-columns/);
|
||||||
|
assert.match(canvas, /Import PDF|导入 PDF/);
|
||||||
|
assert.match(canvas, /Export PDF|导出 PDF/);
|
||||||
|
assert.match(canvas, /canvasKind/);
|
||||||
|
assert.match(canvas, /MAX_PAGES = 50/);
|
||||||
|
assert.match(canvas, /const BUTTON_ICONS =/);
|
||||||
|
assert.match(canvas, /canvas-note-icon/);
|
||||||
|
assert.match(canvas, /canvas-note-tool-group/);
|
||||||
|
assert.match(canvas, /\['flow-text', '全局文本'\]/);
|
||||||
|
assert.match(canvas, /mountFlowText/);
|
||||||
|
assert.match(canvasFlow, /canvasPageBreak/);
|
||||||
|
assert.match(canvasFlow, /columnWidth/);
|
||||||
|
assert.match(canvasFlow, /onPageCount/);
|
||||||
|
assert.match(canvasFlow, /renderPage/);
|
||||||
|
assert.match(canvasFlow, /suppressUserFollowSelection/);
|
||||||
|
assert.match(canvas, /await flowEditor\.flush\(\)/);
|
||||||
|
assert.match(canvas, /insertedPageId/);
|
||||||
|
assert.match(richCss, /\.canvas-flow-toolbar/);
|
||||||
|
assert.match(richCss, /\.canvas-flow-layer/);
|
||||||
|
const toolbarRule = richCss.match(/\.canvas-note-toolbar\s*\{([^}]*)\}/);
|
||||||
|
const viewportRule = richCss.match(/\.canvas-note-viewport\s*\{([^}]*)\}/);
|
||||||
|
const mainCanvasBodyRule = appCss.match(/\.canvas-note-modal \.modal-body\s*\{([^}]*)\}/);
|
||||||
|
const readerCanvasFieldsRule = readerCss.match(
|
||||||
|
/\.canvas-note-modal \.note-editor-fields\s*\{([^}]*)\}/
|
||||||
|
);
|
||||||
|
assert.ok(toolbarRule && viewportRule && mainCanvasBodyRule && readerCanvasFieldsRule);
|
||||||
|
assert.match(toolbarRule[1], /flex-wrap:\s*wrap/);
|
||||||
|
assert.match(toolbarRule[1], /overflow:\s*visible/);
|
||||||
|
assert.match(viewportRule[1], /overflow:\s*auto/);
|
||||||
|
assert.match(mainCanvasBodyRule[1], /overflow:\s*hidden/);
|
||||||
|
assert.match(readerCanvasFieldsRule[1], /overflow:\s*hidden/);
|
||||||
|
assert.match(store, /richImageTotalBytes:\s*8 \* 1024 \* 1024/);
|
||||||
|
assert.match(store, /normalizeCanvasContent/);
|
||||||
|
assert.match(store, /const VERSION = 6/);
|
||||||
|
assert.match(store, /normalizeCanvasFlow/);
|
||||||
|
assert.match(store, /NOTE_TYPES/);
|
||||||
|
assert.match(assets, /reader-note-assets/);
|
||||||
|
assert.match(assets, /senderId/);
|
||||||
|
assert.doesNotMatch(notes, /id="newNoteText"|id="noteEditText"/);
|
||||||
|
const functionAt = browse.indexOf('async function downloadFile');
|
||||||
|
const awaitAt = browse.indexOf('await window.api.library.findBySource', functionAt);
|
||||||
|
const snapshotAt = browse.indexOf('const meta = entryMeta()', functionAt);
|
||||||
|
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记表单控件样式不外溢到工具栏,关联下拉框限宽', () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
const noteWindowCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
|
||||||
|
const fieldRule = css.match(/\.note-edit-form select[^{]*\{([^}]*margin-top[^}]*)\}/);
|
||||||
|
assert.ok(fieldRule, '找不到笔记表单控件规则');
|
||||||
|
// 画布工具栏与 Quill 工具栏都是 .note-edit-form 的后代,
|
||||||
|
// 漏掉任一 :not() 就会把 margin-top / width:100% 灌进工具栏,
|
||||||
|
// 表现为工具栏凭空高出一截、分组高度对不齐
|
||||||
|
assert.match(fieldRule[0], /:not\(\.canvas-note-root select\)/);
|
||||||
|
assert.match(fieldRule[0], /:not\(\.ql-toolbar select\)/);
|
||||||
|
const capRule = css.match(/\.note-edit-form select[^{]*\{([^}]*max-width[^}]*)\}/);
|
||||||
|
assert.ok(capRule, '关联书籍下拉框没有限宽');
|
||||||
|
assert.match(capRule[1], /max-width:\s*320px/);
|
||||||
|
assert.match(capRule[1], /min-width:\s*0/);
|
||||||
|
assert.match(capRule[0], /:not\(\.canvas-note-root select\)/);
|
||||||
|
const metaRule = noteWindowCss.match(/\.note-window-meta select[\s\S]*?\{([^}]*)\}/);
|
||||||
|
assert.ok(metaRule, '笔记窗口下拉框没有限宽规则');
|
||||||
|
assert.match(metaRule[1], /max-width:\s*260px/);
|
||||||
|
assert.match(metaRule[1], /min-width:\s*0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记窗口标题栏只有品牌名,没有副标题', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
|
||||||
|
const titlebar = html.match(/<div class="titlebar">[\s\S]*?<div class="titlebar-spacer">/);
|
||||||
|
assert.ok(titlebar, '找不到笔记窗口标题栏');
|
||||||
|
// style.css 的 .titlebar-left 不是 flex(只有 reader.css 是),
|
||||||
|
// 放同级的 brand-sub 会掉到品牌名下面一行,把标题栏顶高
|
||||||
|
assert.doesNotMatch(titlebar[0], /brand-sub/);
|
||||||
|
assert.doesNotMatch(html, /noteWindowSubtitle/);
|
||||||
|
assert.doesNotMatch(shell, /noteWindowSubtitle|subtitle/);
|
||||||
|
assert.match(titlebar[0], /<span>笔记<\/span>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('笔记窗口多标签:自带标签条样式,非激活视图隐藏,存活编辑器有上限', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
|
||||||
|
assert.match(html, /class="doctabs"/, '缺少标签条');
|
||||||
|
assert.match(html, /id="noteDirtyModal"/, '缺少未保存确认框');
|
||||||
|
// note.html 不加载 reader.css,标签条样式必须在 note-window.css 里自带一份
|
||||||
|
assert.doesNotMatch(html, /reader\.css/);
|
||||||
|
assert.match(css, /\.doctabs\s*\{/, '标签条样式缺失,标签会退化成竖排文字');
|
||||||
|
assert.match(css, /\.note-tab-view\.inactive[\s\S]*?display:\s*none/);
|
||||||
|
// 只留一个可见视图,否则多个 Quill/画布实例同时可见会互相抢焦点
|
||||||
|
assert.match(shell, /MAX_LIVE_EDITORS\s*=\s*\d+/);
|
||||||
|
// 取消关闭必须真的把 cancelClose 发出去:主进程的 closePending 不复位,
|
||||||
|
// 下次点关闭会被当成"正在处理"忽略,而看门狗仍会销毁带未保存内容的窗口
|
||||||
|
const abortAt = shell.indexOf('async function abortClose');
|
||||||
|
assert.ok(abortAt > 0, '缺少 abortClose');
|
||||||
|
const abortBody = shell.slice(abortAt, shell.indexOf('\n}', abortAt));
|
||||||
|
assert.match(abortBody, /await api\.notes\.cancelClose\(\)/);
|
||||||
|
assert.doesNotMatch(abortBody, /if\s*\(\s*true\s*\)\s*return/);
|
||||||
|
assert.match(abortBody, /closing = false/);
|
||||||
|
// 脏判定必须比对序列化内容:靠 keydown/pointerdown 之类的交互事件会误报,
|
||||||
|
// 画布加载时的 1→2 版本归一化本身就会改一次内容
|
||||||
|
assert.match(shell, /baselineKey/);
|
||||||
|
assert.doesNotMatch(shell, /addEventListener\('pointerdown'[\s\S]{0,120}dirty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书架操作对键盘焦点可见', () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
|
||||||
|
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*none/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('侧栏行内操作不占据布局,选中高亮与静态筛选项等宽', () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
// 留在文档流里会占掉约 47px,使书架/标签的选中亮条比「全部书籍」窄一截
|
||||||
|
const actionsRule = css.match(
|
||||||
|
/\.library-shelf-actions,\s*\n\.library-tag-actions\s*\{([^}]*)\}/
|
||||||
|
);
|
||||||
|
assert.ok(actionsRule, '书架与标签操作区应共用同一条规则');
|
||||||
|
assert.match(actionsRule[1], /position:\s*absolute/);
|
||||||
|
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*flex;\s*flex:\s*none/);
|
||||||
|
assert.match(css, /\.library-shelf-row \.library-filter,\s*\n\.library-tag-row \.library-filter\s*\{[^}]*padding-right:\s*48px/);
|
||||||
|
// 书架与标签共用同一个列表容器类,行间距不会一边有一边没有
|
||||||
|
assert.match(css, /\.library-filter-list\s*\{[^}]*gap:\s*1px/);
|
||||||
|
assert.match(html, /id="libraryShelfList" class="library-filter-list"/);
|
||||||
|
assert.match(html, /id="libraryTagList" class="library-filter-list"/);
|
||||||
|
assert.doesNotMatch(css, /\.library-tag-list\s*\{/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书库多选提供全选、批量整理与批量移除', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
|
assert.match(html, /id="librarySelectModeBtn"[^>]*aria-pressed="false"/);
|
||||||
|
assert.match(html, /id="librarySelectAll"/);
|
||||||
|
assert.match(html, /id="libraryBulkOrganizeBtn"[^>]*disabled/);
|
||||||
|
assert.match(html, /id="libraryBulkRemoveBtn"[^>]*disabled/);
|
||||||
|
|
||||||
|
// 全选只覆盖当前筛选结果,且被筛掉的条目要从选中集合里剔除,
|
||||||
|
// 否则会对用户看不见的书执行批量操作
|
||||||
|
assert.match(library, /visibleIds = items\.map\(\(item\) => String\(item\.id\)\)/);
|
||||||
|
assert.match(library, /if \(!visible\.has\(id\)\) selectedIds\.delete\(id\)/);
|
||||||
|
assert.match(library, /visibleIds\.forEach\(\(id\) => selectedIds\.add\(id\)\)/);
|
||||||
|
|
||||||
|
// 多选时封面不能再触发阅读,否则勾选途中会误开阅读器
|
||||||
|
assert.match(library, /const coverActs = readable && !selectMode/);
|
||||||
|
assert.match(library, /\$\{coverActs \? 'data-act="read"/);
|
||||||
|
|
||||||
|
// 批量标签是增量语义:indeterminate 表示部分持有,跳过即保持原样
|
||||||
|
assert.match(library, /if \(box\.indeterminate\) return;/);
|
||||||
|
assert.match(library, /else if \(box\.dataset\.state !== 'none'\) strip\.push/);
|
||||||
|
assert.match(library, /box\.indeterminate = next === 'some'/);
|
||||||
|
assert.doesNotMatch(library, /#libraryBulkTags input\[type="checkbox"\]:checked/);
|
||||||
|
|
||||||
|
// 书架不一致时默认保持不变,不能把所选书籍统一挪走
|
||||||
|
assert.match(library, /if \(shelfValue !== '__keep__'\) patch\.shelfId/);
|
||||||
|
assert.match(library, /value="__keep__" selected>保持不变/);
|
||||||
|
|
||||||
|
// 批量移除沿用单本移除的两个可选项
|
||||||
|
assert.match(library, /id="bulkDelFiles"/);
|
||||||
|
assert.match(library, /id="bulkDelReadingData"/);
|
||||||
|
// 一次 IPC 提交整批,不再逐条 remove:书库索引是整体重写的,循环会造成写放大
|
||||||
|
assert.match(library, /window\.api\.library\.removeMany\(targets\.map\(\(item\) => item\.id\), choice\)/);
|
||||||
|
assert.match(library, /window\.api\.library\.updateMany\(patches\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('卡片定位上下文不随多选状态消失', () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
// 退出多选是同步移除 class,重绘要等 IPC;若定位上下文只在 select-mode 下建立,
|
||||||
|
// 残留的复选框会按视口定位飞到左上角标题上闪一下
|
||||||
|
assert.match(css, /\n\.card\s*\{[^}]*position:\s*relative/);
|
||||||
|
assert.doesNotMatch(css, /#libraryTab\.select-mode \.card\s*\{\s*position:\s*relative/);
|
||||||
|
assert.match(css, /#libraryTab:not\(\.select-mode\) \.card-select\s*\{\s*display:\s*none/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('多选复选框用原生外观,不套衬底色块', () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
const wrap = css.match(/\.card-select\s*\{([^}]*)\}/);
|
||||||
|
assert.ok(wrap, '缺少复选框容器样式');
|
||||||
|
// padding + 背景板会让 13px 的原生复选框看起来套了一圈很粗的边框,
|
||||||
|
// 浅色封面上的可见性改用投影解决
|
||||||
|
assert.doesNotMatch(wrap[1], /padding:\s*[1-9]/);
|
||||||
|
assert.doesNotMatch(wrap[1], /background:\s*rgba/);
|
||||||
|
const input = css.match(/\.card-select input\s*\{([^}]*)\}/);
|
||||||
|
assert.ok(input, '缺少复选框样式');
|
||||||
|
assert.match(input[1], /drop-shadow/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('封面完整显示且比例一致,留白由模糊层填充', () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
const coverRule = css.match(/\n\.card-cover\s*\{([^}]*)\}/);
|
||||||
|
assert.ok(coverRule, '缺少封面样式');
|
||||||
|
// cover 会按各自比例裁掉不同的边,同一批封面看起来缩放程度不一致
|
||||||
|
assert.match(coverRule[1], /center\/contain/);
|
||||||
|
assert.doesNotMatch(coverRule[1], /center\/cover/);
|
||||||
|
// ::before 垫模糊底,::after 画清晰的完整封面。
|
||||||
|
// 只用 ::before 的话它会盖住父元素自己的背景,封面整张变成模糊的
|
||||||
|
// 共用规则里 ::after 也单独占一行,所以不能靠 \n 区分;
|
||||||
|
// 按"独占一条规则"来取:选择器后面直接跟 { 且规则里带 z-index
|
||||||
|
const rules = [...css.matchAll(
|
||||||
|
/\.card-cover\[data-cover-state="ready"\]::(before|after)\s*\{([^}]*)\}/g
|
||||||
|
)].filter((m) => /z-index/.test(m[2]));
|
||||||
|
const before = rules.find((m) => m[1] === 'before');
|
||||||
|
const after = rules.find((m) => m[1] === 'after');
|
||||||
|
assert.ok(before && after, '缺少封面双层背景规则');
|
||||||
|
assert.match(before[2], /background-size:\s*cover/);
|
||||||
|
assert.match(before[2], /blur\(/);
|
||||||
|
assert.match(after[2], /background-size:\s*contain/);
|
||||||
|
// 模糊层必须在下、清晰层在上
|
||||||
|
assert.ok(
|
||||||
|
Number(before[2].match(/z-index:\s*(\d+)/)[1]) < Number(after[2].match(/z-index:\s*(\d+)/)[1]),
|
||||||
|
'模糊层盖住了清晰封面'
|
||||||
|
);
|
||||||
|
// 角标与占位文字要浮在两层背景之上
|
||||||
|
assert.match(css, /\.card-cover > \*\s*\{[^}]*z-index:\s*2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('可内置阅读的格式在渲染层与 main.js 保持一致', () => {
|
||||||
|
const mainSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||||
|
const readable = mainSrc.match(/const READABLE_EXT = new Set\(\[([^\]]*)\]\)/);
|
||||||
|
assert.ok(readable, '找不到 READABLE_EXT');
|
||||||
|
const exts = readable[1].match(/\.\w+/g).map((s) => s.slice(1)).sort();
|
||||||
|
// 渲染层漏掉格式不会报错,只是「阅读」按钮和封面点击静默消失,
|
||||||
|
// 用户看到的现象就是"内置阅读器打不开 txt"
|
||||||
|
for (const file of [
|
||||||
|
path.join(__dirname, '..', 'ui', 'views', 'library.js'),
|
||||||
|
path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs')
|
||||||
|
]) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
const re = src.match(/READABLE_RE\s*=\s*\/\\\.\(([^)]*)\)\$\/i/);
|
||||||
|
assert.ok(re, `${path.basename(file)} 缺少 READABLE_RE`);
|
||||||
|
assert.deepStrictEqual(re[1].split('|').sort(), exts, `${path.basename(file)} 的可阅读格式与 main.js 不一致`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('状态、笔记与标签标识叠在封面上,不占用封面下方行', () => {
|
||||||
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
|
||||||
|
// 三种标识都必须在 .card-cover 内部,否则又会各占一行
|
||||||
|
const cover = library.match(/<div class="card-cover\$\{[\s\S]*?\n\s*<\/div>/);
|
||||||
|
assert.ok(cover, '封面结构缺失');
|
||||||
|
assert.match(cover[0], /class="library-card-tags"/);
|
||||||
|
// 下载状态在左下角,批注与笔记在右下角
|
||||||
|
assert.match(cover[0], /class="card-cover-badges start">\$\{badge\}/);
|
||||||
|
assert.match(cover[0], /class="card-cover-badges end">\$\{annotationBadge\}\$\{noteBadge\}/);
|
||||||
|
// 标识不能再出现在封面之后、标题周围
|
||||||
|
const afterCover = library.slice(library.indexOf('class="card-title"'));
|
||||||
|
assert.doesNotMatch(afterCover.slice(0, 400), /card-cover-badges|library-card-tags/);
|
||||||
|
|
||||||
|
const coverRule = css.match(/\.card-cover\s*\{([^}]*)\}/);
|
||||||
|
assert.match(coverRule[1], /position:\s*relative/);
|
||||||
|
assert.match(coverRule[1], /overflow:\s*hidden/);
|
||||||
|
|
||||||
|
const badgeWrap = css.match(/\.card-cover-badges\s*\{([^}]*)\}/);
|
||||||
|
assert.ok(badgeWrap, '缺少封面标识容器样式');
|
||||||
|
assert.match(badgeWrap[1], /position:\s*absolute/);
|
||||||
|
assert.match(badgeWrap[1], /bottom:\s*6px/);
|
||||||
|
// 标识浮在封面上,必须让点击穿透到封面的阅读入口
|
||||||
|
assert.match(badgeWrap[1], /pointer-events:\s*none/);
|
||||||
|
// 左右两组各占一半,避免笔记批注多时与左下角的下载状态叠在一起
|
||||||
|
assert.match(badgeWrap[1], /max-width:\s*calc\(50% - 8px\)/);
|
||||||
|
assert.match(css, /\.card-cover-badges\.start\s*\{\s*left:\s*6px/);
|
||||||
|
assert.match(css, /\.card-cover-badges\.end\s*\{\s*right:\s*6px/);
|
||||||
|
|
||||||
|
const tagsRule = css.match(/\.library-card-tags\s*\{([^}]*)\}/);
|
||||||
|
assert.match(tagsRule[1], /position:\s*absolute/);
|
||||||
|
assert.match(tagsRule[1], /top:\s*6px/);
|
||||||
|
assert.match(tagsRule[1], /pointer-events:\s*none/);
|
||||||
|
// 左上角留给多选复选框,标签宽度必须扣掉这块
|
||||||
|
assert.match(tagsRule[1], /max-width:\s*calc\(100% - 42px\)/);
|
||||||
|
// 允许换行会让三个长标签堆成三行盖住封面
|
||||||
|
assert.doesNotMatch(tagsRule[1], /flex-wrap:\s*wrap/);
|
||||||
|
assert.match(library, /class="library-card-tag" title="\$\{escapeHtml\(tag\)\}"/);
|
||||||
|
|
||||||
|
// 封面图案深浅不可控,衬底必须不透明
|
||||||
|
const badgeRule = css.match(/\n\.card-badge\s*\{([^}]*)\}/);
|
||||||
|
assert.match(badgeRule[1], /background:\s*var\(--bg-card\)/);
|
||||||
|
assert.doesNotMatch(badgeRule[1], /margin-top/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
||||||
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
|
const titleRule = css.match(/\.card-title\s*\{([^}]*)\}/);
|
||||||
|
assert.ok(titleRule, '缺少书库标题样式');
|
||||||
|
assert.match(titleRule[1], /white-space:\s*nowrap/);
|
||||||
|
assert.match(titleRule[1], /overflow:\s*hidden/);
|
||||||
|
assert.match(titleRule[1], /text-overflow:\s*ellipsis/);
|
||||||
|
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 面板以线程容器承载多轮对话气泡', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||||
|
|
||||||
|
// 结构约定要写在 HTML 里,集成测试与 CSS 都按 ai-thread / ai-msg 定位
|
||||||
|
assert.match(html, /id="aiOutput" class="ai-output ai-thread"/);
|
||||||
|
assert.match(shell, /box\.className = `ai-msg ai-msg-\$\{role\}`/);
|
||||||
|
assert.match(shell, /box\.dataset\.messageId = String\(message\.id \|\| ''\)/);
|
||||||
|
assert.match(shell, /body\.className = 'ai-msg-body'/);
|
||||||
|
assert.match(shell, /meta\.className = 'ai-msg-meta'/);
|
||||||
|
assert.match(css, /\.ai-msg-body\s*\{/);
|
||||||
|
assert.match(css, /\.ai-thread\s*\{[^}]*flex-direction:\s*column/);
|
||||||
|
|
||||||
|
// 新回答必须追加而不是覆盖:整块重写等于回到一问一答
|
||||||
|
assert.match(shell, /function appendAiMessage\(message\)/);
|
||||||
|
assert.match(shell, /el\.aiOutput\.appendChild\(node\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 流式增量只重渲染正在生成的那一条气泡', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
// 线程里有几十条消息时,每 80ms 重解析全部 Markdown 会把界面拖死,
|
||||||
|
// 因此增量渲染只允许写 aiStreamNode 里的 .ai-msg-body
|
||||||
|
const render = shell.match(/function renderAiOutput\([\s\S]*?\n\}/);
|
||||||
|
assert.ok(render, '缺少 renderAiOutput');
|
||||||
|
assert.match(render[0], /aiStreamNode\.querySelector\('\.ai-msg-body'\)/);
|
||||||
|
assert.match(render[0], /renderMessageBody\(body,/);
|
||||||
|
assert.doesNotMatch(render[0], /AiMarkdown\.mount\(el\.aiOutput/);
|
||||||
|
assert.doesNotMatch(render[0], /renderAiThread\(\)/);
|
||||||
|
// 整篇重建只发生在换会话时,不能出现在节流的增量路径上
|
||||||
|
const schedule = shell.match(/function scheduleAiOutput\([\s\S]*?\n\}/);
|
||||||
|
assert.ok(schedule, '缺少 scheduleAiOutput');
|
||||||
|
assert.doesNotMatch(schedule[0], /renderAiThread\(\)/);
|
||||||
|
assert.doesNotMatch(schedule[0], /AiMarkdown\.mount\(el\.aiOutput/);
|
||||||
|
|
||||||
|
// 80ms 节流与「贴底才自动滚动」都要保留
|
||||||
|
assert.match(shell, /aiRenderTimer = window\.setTimeout\([\s\S]{0,120}\}, 80\)/);
|
||||||
|
assert.match(shell, /el\.aiOutput\.scrollHeight - el\.aiOutput\.scrollTop - el\.aiOutput\.clientHeight < 48/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 增量按 messageId 路由到对应气泡', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const delta = shell.match(/unsubDelta = api\.ai\.onDelta\([\s\S]*?\n \}\);/);
|
||||||
|
assert.ok(delta, '缺少 onDelta 订阅');
|
||||||
|
// 只按 runId 过滤会让同一次运行里的旧气泡也收到增量,串成一团
|
||||||
|
assert.match(delta[0], /const messageId = d\.messageId \? String\(d\.messageId\) : ''/);
|
||||||
|
assert.match(delta[0], /messageId !== aiRun\.messageId\) return/);
|
||||||
|
assert.match(shell, /function adoptStreamingMessageId\(messageId\)/);
|
||||||
|
assert.match(shell, /aiRun\.messageId = String\(messageId\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话管理提供新建、重命名、置顶、清空与删除', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /id="aiSessionSelect"[^>]+title="切换当前书籍的对话会话"/);
|
||||||
|
assert.match(html, /id="aiSessionNewBtn"[^>]*>新建</);
|
||||||
|
assert.match(html, /id="aiSessionRenameBtn"[^>]*>重命名</);
|
||||||
|
assert.match(html, /id="aiSessionPinBtn"[^>]*>置顶</);
|
||||||
|
assert.match(html, /id="aiSessionClearBtn"[^>]*>清空</);
|
||||||
|
assert.match(html, /id="aiSessionDeleteBtn"[^>]*>删除</);
|
||||||
|
|
||||||
|
// 会话下拉按 pinned 优先、updatedAt 倒序,标题为空时显示「新会话」
|
||||||
|
assert.match(shell, /a\.pinned \? -1 : 1/);
|
||||||
|
assert.match(shell, /Number\(b\.updatedAt\) \|\| 0\) - \(Number\(a\.updatedAt\) \|\| 0\)/);
|
||||||
|
assert.match(shell, /return title \|\| '新会话'/);
|
||||||
|
|
||||||
|
// 一开书就建空会话会很快占满 100 个上限,必须延迟到首次提问
|
||||||
|
assert.match(shell, /async function ensureAiSession\(\)/);
|
||||||
|
assert.match(shell, /if \(aiSessionId\) return aiSessionId/);
|
||||||
|
const activateBlock = shell.match(/async function activate\(id\)[\s\S]*?\n\}/);
|
||||||
|
assert.ok(activateBlock);
|
||||||
|
assert.match(activateBlock[0], /refreshAiSessions\(\)/);
|
||||||
|
assert.doesNotMatch(activateBlock[0], /sessions\.create\(/);
|
||||||
|
|
||||||
|
// 生成中禁止切会话、删会话与发新问题
|
||||||
|
assert.match(shell, /el\.aiSessionSelect\.disabled = busy \|\| !hasEntry/);
|
||||||
|
assert.match(shell, /el\.aiSessionDeleteBtn\.disabled = busy \|\| !hasSession/);
|
||||||
|
assert.match(shell, /function aiBusy\(busy\)[\s\S]{0,400}syncAiSessionControls\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('删除与清空 AI 会话都要走应用内二次确认', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /id="aiSessionDeleteModal"[\s\S]*id="aiSessionDeleteConfirmBtn"[^>]*>删除会话</);
|
||||||
|
assert.match(html, /id="aiSessionClearModal"[\s\S]*id="aiSessionClearConfirmBtn"[^>]*>清空消息</);
|
||||||
|
assert.match(html, /id="aiSessionRenameModal"[\s\S]*id="aiSessionTitleInput"/);
|
||||||
|
|
||||||
|
// 对话记录删掉找不回来,必须先确认再调 remove/clear
|
||||||
|
const remove = shell.match(/async function deleteAiSession\(\)[\s\S]*?\n\}/);
|
||||||
|
assert.ok(remove, '缺少 deleteAiSession');
|
||||||
|
const removeConfirmAt = remove[0].indexOf('await confirmAiSessionDelete(');
|
||||||
|
const removeCallAt = remove[0].indexOf('sessions.remove(');
|
||||||
|
assert.ok(removeConfirmAt >= 0, '删除会话缺少二次确认,会一键抹掉全部对话记录');
|
||||||
|
assert.ok(removeCallAt >= 0, '删除会话未调用 sessions.remove');
|
||||||
|
assert.ok(removeConfirmAt < removeCallAt, '删除会话必须先弹二次确认再调 sessions.remove');
|
||||||
|
const clear = shell.match(/async function clearAiSession\(\)[\s\S]*?\n\}/);
|
||||||
|
assert.ok(clear, '缺少 clearAiSession');
|
||||||
|
const clearConfirmAt = clear[0].indexOf('await confirmAiSessionClear(');
|
||||||
|
const clearCallAt = clear[0].indexOf('sessions.clear(');
|
||||||
|
assert.ok(clearConfirmAt >= 0, '清空会话缺少二次确认,消息删掉找不回来');
|
||||||
|
assert.ok(clearCallAt >= 0, '清空会话未调用 sessions.clear');
|
||||||
|
assert.ok(clearConfirmAt < clearCallAt, '清空会话必须先弹二次确认再调 sessions.clear');
|
||||||
|
assert.match(shell, /function confirmAiSessionDelete\(title\)[\s\S]{0,400}aiSessionDeleteModal\.classList\.remove\('hidden'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话调用 api.ai.sessions 契约并带上 sessionId 发送', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /api\.ai && api\.ai\.sessions \? api\.ai\.sessions : null/);
|
||||||
|
assert.match(shell, /sessions\.list\(\{ entryId \}\)/);
|
||||||
|
assert.match(shell, /sessions\.create\(\{ entryId, title: '', documentKey: tab\.documentKey \|\| null \}\)/);
|
||||||
|
assert.match(shell, /sessions\.rename\(aiSessionId, title\)/);
|
||||||
|
assert.match(shell, /sessions\.setPinned\(aiSessionId, next\)/);
|
||||||
|
assert.match(shell, /sessions\.remove\(removed\)/);
|
||||||
|
assert.match(shell, /sessions\.clear\(aiSessionId\)/);
|
||||||
|
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_THREAD_LIMIT \}\)/);
|
||||||
|
// sessionId 不下发就退化成不落盘的一次性提问,线程无法续接
|
||||||
|
assert.match(shell, /api\.ai\.run\(\{\s*\n\s*runId,\s*\n\s*sessionId,/);
|
||||||
|
assert.match(shell, /res\.data\.assistantMessageId|ids\.assistantMessageId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 每条回答各自提供保存此回答与复制', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /save\.className = 'tb-btn sm ai-msg-save'/);
|
||||||
|
assert.match(shell, /save\.textContent = '保存此回答'/);
|
||||||
|
assert.match(shell, /copy\.className = 'tb-btn ghost sm ai-msg-copy'/);
|
||||||
|
assert.match(shell, /saveAiNote\(aiResultOf\(box\.dataset\.messageId\)\)/);
|
||||||
|
assert.match(shell, /copyAiMessage\(box\.dataset\.messageId\)/);
|
||||||
|
// locator 取该条对应 user 消息的 contextRef,quote 取那条问题文本
|
||||||
|
assert.match(shell, /if \(aiThread\[i\]\.role === 'user'\) \{ ask = aiThread\[i\]; break; \}/);
|
||||||
|
assert.match(shell, /locator: \(ref && ref\.locator\) \|\| null/);
|
||||||
|
assert.match(shell, /quote: ask \? String\(ask\.text \|\| ''\) : ''/);
|
||||||
|
assert.match(shell, /async function saveAiNote\(target\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话可按最近轮数或全部保留消息保存为一条笔记', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /id="aiSaveBtn"[^>]+title="把当前会话保存为一条笔记"[^>]*>保存会话…</);
|
||||||
|
assert.match(html, /id="aiSessionSaveModal"[\s\S]*id="aiSessionSaveRecent"/);
|
||||||
|
assert.match(html, /id="aiSessionSaveRounds"[^>]+type="number"[^>]+min="1"[^>]+max="100"/);
|
||||||
|
assert.match(html, /id="aiSessionSaveAll"[\s\S]*当前保留会话全部/);
|
||||||
|
assert.match(html, /id="aiSessionSaveRoundCount"[\s\S]*id="aiSessionSaveCharCount"/);
|
||||||
|
|
||||||
|
// 保存时必须另取后端当前保留的全部 200 条,不能复用界面当前 60 条
|
||||||
|
assert.match(shell, /const AI_THREAD_LIMIT = 60/);
|
||||||
|
assert.match(shell, /const AI_SESSION_SAVE_MESSAGE_LIMIT = 200/);
|
||||||
|
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_SESSION_SAVE_MESSAGE_LIMIT \}\)/);
|
||||||
|
assert.match(shell, /function aiConversationRounds\(messages\)/);
|
||||||
|
assert.match(shell, /message\.role === 'user'[\s\S]{0,220}message\.role === 'assistant' && user/);
|
||||||
|
assert.match(shell, /return all\.slice\(-count\)/);
|
||||||
|
|
||||||
|
// 范围和 N 记住上次选择,确认前展示真实轮数和最终正文字符数
|
||||||
|
assert.match(shell, /const AI_SESSION_SAVE_SETTING = 'reader\.aiSessionSave'/);
|
||||||
|
assert.match(shell, /api\.settings\.get\(AI_SESSION_SAVE_SETTING, aiSessionSavePreference\)/);
|
||||||
|
assert.match(shell, /api\.settings\.set\(AI_SESSION_SAVE_SETTING, preference\)/);
|
||||||
|
assert.match(shell, /aiSessionSaveRoundCount\.textContent = `\$\{selected\.length\.toLocaleString\(\)\} 轮`/);
|
||||||
|
assert.match(shell, /aiSessionSaveCharCount\.textContent = `\$\{text\.length\.toLocaleString\(\)\} 字`/);
|
||||||
|
|
||||||
|
const saveStart = shell.indexOf('async function saveAiSessionNote()');
|
||||||
|
const saveEnd = shell.indexOf('\nasync function renameAiSession()', saveStart);
|
||||||
|
assert.ok(saveStart >= 0 && saveEnd > saveStart, '缺少保存会话实现');
|
||||||
|
const saveBlock = shell.slice(saveStart, saveEnd);
|
||||||
|
assert.strictEqual((saveBlock.match(/api\.reader\.addNote\(/g) || []).length, 1, '一次会话保存只能调用一次 addNote');
|
||||||
|
assert.match(saveBlock, /title: state\.title/);
|
||||||
|
assert.match(saveBlock, /\n\s+text,/);
|
||||||
|
assert.match(saveBlock, /quote: ''/);
|
||||||
|
assert.match(saveBlock, /locator: null/);
|
||||||
|
assert.match(saveBlock, /已将 \$\{selected\.length\.toLocaleString\(\)\} 轮会话(\$\{text\.length\.toLocaleString\(\)\} 字)保存为一条笔记/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话笔记正文标明问答结构与未保留的较早消息', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /`## 第 \$\{index \+ 1\} 轮\\n\\n### 问题\\n\\n\$\{question\}\\n\\n### 回答\\n\\n\$\{answer\}`/);
|
||||||
|
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不在当前保留会话中,因此未保存/);
|
||||||
|
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已被会话存储上限淘汰,不会出现在笔记中/);
|
||||||
|
assert.match(shell, /会话正文将完整写入一条笔记,不会拆分,也不会在此处截断/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /const parts = \[scopeName\(ref\.scope\)\]/);
|
||||||
|
assert.match(shell, /parts\.push\(`\$\{chars\.toLocaleString\(\)\} 字`\)/);
|
||||||
|
assert.match(shell, /`上下文:\$\{parts\.join\(' · '\)\}`/);
|
||||||
|
assert.match(shell, /if \(message\.truncated\) flags\.push\('内容已裁剪'\)/);
|
||||||
|
assert.match(shell, /if \(message\.cancelled\) flags\.push\('已停止生成'\)/);
|
||||||
|
assert.match(shell, /box\.classList\.add\('ai-msg-error'\)/);
|
||||||
|
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不再保留/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 线程里的模型输出全部经 AiMarkdown 渲染,不直接写 innerHTML', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
// 渲染层 CSP 挡不住 DOM 注入,模型文本必须过 AiMarkdown 内的 DOMPurify
|
||||||
|
assert.match(shell, /function renderMessageBody\(body, source\)[\s\S]{0,300}window\.AiMarkdown\.mount\(body, source\)/);
|
||||||
|
assert.match(shell, /else renderMessageBody\(body, message\.text\)/);
|
||||||
|
assert.doesNotMatch(shell, /\.innerHTML\s*=/);
|
||||||
|
// 用户输入按纯文本落地,同样不经 HTML 解析
|
||||||
|
assert.match(shell, /if \(role === 'user'\) body\.textContent = String\(message\.text \|\| ''\)/);
|
||||||
|
// 链接拦截仍挂在整个线程容器上,新增气泡里的链接不会漏掉
|
||||||
|
assert.match(shell, /el\.aiOutput\.addEventListener\('click', activateAiLink\)/);
|
||||||
|
assert.match(shell, /el\.aiOutput\.addEventListener\('auxclick', activateAiLink\)/);
|
||||||
|
assert.match(shell, /if \(!link \|\| !el\.aiOutput\.contains\(link\)\) return/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
|
||||||
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
|
assert.match(library, /data-act="read" role="button" tabindex="0"/);
|
||||||
|
assert.match(library, /cover\.onclick[\s\S]*onAction\(id, 'read'\)/);
|
||||||
|
assert.match(library, /event\.key !== 'Enter' && event\.key !== ' '/);
|
||||||
|
assert.match(library, /window\.api\.reader\.open\(id, idx >= 0 \? idx : undefined\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// 元数据落盘的统一入口。
|
||||||
|
//
|
||||||
|
// rename 只保证目录项替换是原子的,不保证被替换的数据已经到磁盘:
|
||||||
|
// 断电后可能出现"改名成功但文件内容是一段空洞"的结果,长度正常、内容全零。
|
||||||
|
// 因此改名前必须先 fsync 数据本身,改名后再 fsync 目录让目录项落盘。
|
||||||
|
// Windows 不允许对目录取句柄,那一步失败时忽略即可。
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function writeSynced(file, data, encoding = 'utf8') {
|
||||||
|
const fd = fs.openSync(file, 'w');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(fd, data, encoding === null ? undefined : { encoding });
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectory(dir) {
|
||||||
|
let fd = null;
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(dir, 'r');
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} catch (e) {
|
||||||
|
// Windows 无法 fsync 目录;其它平台失败也不该让写入整体失败
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) {
|
||||||
|
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 dest,保留一份 .bak 以便下次读取时恢复。
|
||||||
|
// 失败时清理临时文件,并在目标已被改走时把备份换回去。
|
||||||
|
function writeJson(dest, value) {
|
||||||
|
const temp = `${dest}.tmp`;
|
||||||
|
const backup = `${dest}.bak`;
|
||||||
|
let backedUp = false;
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
try {
|
||||||
|
writeSynced(temp, JSON.stringify(value, null, 2));
|
||||||
|
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
fs.renameSync(dest, backup);
|
||||||
|
backedUp = true;
|
||||||
|
}
|
||||||
|
fs.renameSync(temp, dest);
|
||||||
|
syncDirectory(path.dirname(dest));
|
||||||
|
if (backedUp) {
|
||||||
|
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响写入结果 */ }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
|
||||||
|
try {
|
||||||
|
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
||||||
|
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
|
||||||
|
function writeBytesExclusive(dest, bytes) {
|
||||||
|
const temp = `${dest}.tmp`;
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
const fd = fs.openSync(temp, 'wx');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(fd, bytes);
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
fs.renameSync(temp, dest);
|
||||||
|
syncDirectory(path.dirname(dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive };
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { BrowserWindow, ipcMain } = require('electron');
|
||||||
|
|
||||||
|
const SUPPORTED = new Set(['.pdf', '.epub']);
|
||||||
|
const MAX_FILE_SIZE = 256 * 1024 * 1024;
|
||||||
|
const TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
let rootDir = null;
|
||||||
|
let library = null;
|
||||||
|
let renderer = null;
|
||||||
|
let readyPromise = null;
|
||||||
|
let readyResolve = null;
|
||||||
|
let readyReject = null;
|
||||||
|
let active = null;
|
||||||
|
let sequence = 0;
|
||||||
|
let shuttingDown = false;
|
||||||
|
let renderTail = Promise.resolve();
|
||||||
|
let pendingRenders = 0;
|
||||||
|
const jobs = new Map();
|
||||||
|
const STALE = Symbol('stale-cover-input');
|
||||||
|
|
||||||
|
function localCoverExists(cover) {
|
||||||
|
return /^data:image\//i.test(cover || '')
|
||||||
|
|| (!!cover && !/^https?:\/\//i.test(cover) && fs.existsSync(cover));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readableFile(entry) {
|
||||||
|
return (entry.files || []).find((file) => {
|
||||||
|
if (!file || !file.path || file.exists === false) return false;
|
||||||
|
return SUPPORTED.has(path.extname(file.path).toLowerCase()) && fs.existsSync(file.path);
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRenderer() {
|
||||||
|
if (shuttingDown) return Promise.reject(new Error('应用正在退出'));
|
||||||
|
if (renderer && !renderer.isDestroyed()) return readyPromise;
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 400,
|
||||||
|
height: 500,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(rootDir, 'src', 'ui', 'cover-preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
spellcheck: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderer = win;
|
||||||
|
const webContentsId = win.webContents.id;
|
||||||
|
readyPromise = new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error('封面渲染器启动超时'));
|
||||||
|
if (!win.isDestroyed()) win.destroy();
|
||||||
|
}, 10000);
|
||||||
|
readyResolve = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
readyReject = (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const pendingReady = readyPromise;
|
||||||
|
win.loadFile(path.join(rootDir, 'src', 'ui', 'cover-renderer.html')).catch((error) => {
|
||||||
|
if (renderer === win && readyReject) readyReject(error);
|
||||||
|
if (renderer === win) {
|
||||||
|
readyResolve = null;
|
||||||
|
readyReject = null;
|
||||||
|
}
|
||||||
|
if (!win.isDestroyed()) win.destroy();
|
||||||
|
});
|
||||||
|
win.webContents.on('render-process-gone', () => {
|
||||||
|
if (!win.isDestroyed()) win.destroy();
|
||||||
|
});
|
||||||
|
win.on('closed', () => {
|
||||||
|
if (renderer === win) {
|
||||||
|
renderer = null;
|
||||||
|
readyPromise = null;
|
||||||
|
readyResolve = null;
|
||||||
|
if (readyReject) readyReject(new Error('封面渲染器已关闭'));
|
||||||
|
readyReject = null;
|
||||||
|
}
|
||||||
|
if (active && active.webContentsId === webContentsId) {
|
||||||
|
finishActive(new Error('封面渲染器已关闭'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return pendingReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishActive(error, dataUrl) {
|
||||||
|
const job = active;
|
||||||
|
if (!job) return;
|
||||||
|
active = null;
|
||||||
|
clearTimeout(job.timer);
|
||||||
|
if (error) job.reject(error);
|
||||||
|
else job.resolve(dataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.on('cover:ready', (event) => {
|
||||||
|
if (!renderer || renderer.isDestroyed() || event.sender.id !== renderer.webContents.id) return;
|
||||||
|
if (readyResolve) readyResolve();
|
||||||
|
readyResolve = null;
|
||||||
|
readyReject = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('cover:result', (event, result) => {
|
||||||
|
if (!renderer || renderer.isDestroyed() || event.sender.id !== renderer.webContents.id || !active) return;
|
||||||
|
if (!result || result.id !== active.id) return;
|
||||||
|
if (!result.ok) finishActive(new Error(result.error || '封面生成失败'));
|
||||||
|
else finishActive(null, result.dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function extract(entry, file) {
|
||||||
|
await createRenderer();
|
||||||
|
if (!renderer || renderer.isDestroyed()) throw new Error('封面渲染器不可用');
|
||||||
|
if (active) throw new Error('封面渲染器正忙');
|
||||||
|
const stat = await fs.promises.stat(file.path);
|
||||||
|
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_FILE_SIZE) {
|
||||||
|
throw new Error('文件过大或无效,未生成封面');
|
||||||
|
}
|
||||||
|
const bytes = await fs.promises.readFile(file.path);
|
||||||
|
if (bytes.length > MAX_FILE_SIZE) throw new Error('文件过大,未生成封面');
|
||||||
|
const id = `cover-${++sequence}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
active = {
|
||||||
|
id,
|
||||||
|
webContentsId: renderer.webContents.id,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
const win = renderer;
|
||||||
|
finishActive(new Error('封面生成超时'));
|
||||||
|
if (win && !win.isDestroyed()) win.destroy();
|
||||||
|
}, TIMEOUT_MS)
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
renderer.webContents.send('cover:extract', {
|
||||||
|
id,
|
||||||
|
format: path.extname(file.path).slice(1).toLowerCase(),
|
||||||
|
bytes,
|
||||||
|
title: entry.title,
|
||||||
|
authors: entry.authors || []
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
finishActive(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuedExtract(entry, file) {
|
||||||
|
pendingRenders++;
|
||||||
|
const result = renderTail.catch(() => {}).then(() => extract(entry, file));
|
||||||
|
renderTail = result.catch(() => {});
|
||||||
|
return result.finally(() => {
|
||||||
|
pendingRenders--;
|
||||||
|
if (!pendingRenders && renderer && !renderer.isDestroyed()) renderer.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jpegBytes(dataUrl) {
|
||||||
|
const match = String(dataUrl || '').match(/^data:image\/jpeg;base64,([a-z0-9+/=\r\n]+)$/i);
|
||||||
|
if (!match) throw new Error('封面渲染器返回了无效图片');
|
||||||
|
const bytes = Buffer.from(match[1], 'base64');
|
||||||
|
if (bytes.length < 4 || bytes.length > 2 * 1024 * 1024) throw new Error('生成的封面大小无效');
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generationInput(entry, file) {
|
||||||
|
const stat = await fs.promises.stat(file.path);
|
||||||
|
return JSON.stringify({
|
||||||
|
path: path.resolve(file.path),
|
||||||
|
size: stat.size,
|
||||||
|
modified: stat.mtimeMs,
|
||||||
|
title: entry.title || '',
|
||||||
|
authors: entry.authors || []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate(entryId, libraryRoot) {
|
||||||
|
if (shuttingDown || library.getRoot() !== libraryRoot) return null;
|
||||||
|
let entry = library.get(entryId);
|
||||||
|
if (!entry || localCoverExists(entry.cover)) return entry;
|
||||||
|
|
||||||
|
const expectedCover = entry.cover || '';
|
||||||
|
if (/^https?:\/\//i.test(entry.cover || '')) {
|
||||||
|
const cached = await library.ensureCoverCached(entry.id);
|
||||||
|
if (library.getRoot() !== libraryRoot) return null;
|
||||||
|
entry = library.get(entry.id);
|
||||||
|
if (!entry || cached || localCoverExists(entry.cover)) return entry;
|
||||||
|
if (entry.cover !== expectedCover) return STALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = readableFile(entry);
|
||||||
|
if (!file) return entry;
|
||||||
|
const input = await generationInput(entry, file);
|
||||||
|
const dataUrl = await queuedExtract(entry, file);
|
||||||
|
if (library.getRoot() !== libraryRoot) return null;
|
||||||
|
const current = library.get(entry.id);
|
||||||
|
if (!current) return null;
|
||||||
|
if (current.cover !== expectedCover) return STALE;
|
||||||
|
const currentFile = readableFile(current);
|
||||||
|
if (!currentFile || await generationInput(current, currentFile) !== input) return STALE;
|
||||||
|
library.setGeneratedCover(entry.id, jpegBytes(dataUrl), expectedCover);
|
||||||
|
return library.get(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure(entryId) {
|
||||||
|
const id = String(entryId || '');
|
||||||
|
if (!id) return Promise.resolve(null);
|
||||||
|
const libraryRoot = library.getRoot();
|
||||||
|
const key = `${libraryRoot}\0${id}`;
|
||||||
|
if (jobs.has(key)) return jobs.get(key);
|
||||||
|
const job = (async () => {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const result = await generate(id, libraryRoot);
|
||||||
|
if (result !== STALE) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})().finally(() => {
|
||||||
|
jobs.delete(key);
|
||||||
|
});
|
||||||
|
jobs.set(key, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAll() {
|
||||||
|
if (!library) return [];
|
||||||
|
return library.list().map((entry) => ensure(entry.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(appRoot, libraryStore) {
|
||||||
|
rootDir = path.resolve(appRoot);
|
||||||
|
library = libraryStore;
|
||||||
|
shuttingDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
shuttingDown = true;
|
||||||
|
if (active) finishActive(new Error('应用正在退出'));
|
||||||
|
if (readyReject) readyReject(new Error('应用正在退出'));
|
||||||
|
if (renderer && !renderer.isDestroyed()) renderer.destroy();
|
||||||
|
renderer = null;
|
||||||
|
readyPromise = null;
|
||||||
|
readyResolve = null;
|
||||||
|
readyReject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, ensure, ensureAll, close };
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const BOOK_EXT = new Set([
|
||||||
|
'pdf',
|
||||||
|
'epub',
|
||||||
|
'mobi',
|
||||||
|
'azw',
|
||||||
|
'azw3',
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'djvu',
|
||||||
|
'fb2',
|
||||||
|
'cbz',
|
||||||
|
'cbr'
|
||||||
|
]);
|
||||||
|
const DEFAULT_MAX_FILES = 10_000;
|
||||||
|
|
||||||
|
function pathKey(value) {
|
||||||
|
return process.platform === 'win32' ? value.toLowerCase() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePaths(left, right) {
|
||||||
|
const leftKey = pathKey(left);
|
||||||
|
const rightKey = pathKey(right);
|
||||||
|
if (leftKey < rightKey) return -1;
|
||||||
|
if (leftKey > rightKey) return 1;
|
||||||
|
if (left < right) return -1;
|
||||||
|
if (left > right) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeLstat(value) {
|
||||||
|
try {
|
||||||
|
return await fs.lstat(value);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canonicalEntry(value, expectedType) {
|
||||||
|
const before = await safeLstat(value);
|
||||||
|
if (!before || before.isSymbolicLink() || !before[expectedType]()) return null;
|
||||||
|
|
||||||
|
let canonical;
|
||||||
|
try {
|
||||||
|
canonical = await fs.realpath(value);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recheck the directory entry after realpath so an entry changed to a link
|
||||||
|
// during discovery is not intentionally traversed or imported.
|
||||||
|
const after = await safeLstat(value);
|
||||||
|
if (!after || after.isSymbolicLink() || !after[expectedType]()) return null;
|
||||||
|
return path.resolve(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maximumFrom(options) {
|
||||||
|
if (options && Object.prototype.hasOwnProperty.call(options, 'maxFiles')) {
|
||||||
|
const maximum = options.maxFiles;
|
||||||
|
if (!Number.isSafeInteger(maximum) || maximum < 1) {
|
||||||
|
throw new TypeError('本地导入文件数量上限必须是正整数');
|
||||||
|
}
|
||||||
|
return maximum;
|
||||||
|
}
|
||||||
|
return DEFAULT_MAX_FILES;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discover(paths, options = {}) {
|
||||||
|
const maximum = maximumFrom(options);
|
||||||
|
const selected = Array.isArray(paths) ? paths : [paths];
|
||||||
|
const candidates = selected
|
||||||
|
.filter((value) => typeof value === 'string' && value.length > 0)
|
||||||
|
.map((value) => path.resolve(value))
|
||||||
|
.sort(comparePaths);
|
||||||
|
|
||||||
|
const records = [];
|
||||||
|
const seenFiles = new Set();
|
||||||
|
const visitedDirectories = new Set();
|
||||||
|
|
||||||
|
function addFile(canonical) {
|
||||||
|
const key = pathKey(canonical);
|
||||||
|
if (seenFiles.has(key)) return;
|
||||||
|
if (records.length >= maximum) {
|
||||||
|
throw new Error(`本地导入文件数量超过上限(最多 ${maximum} 个)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
seenFiles.add(key);
|
||||||
|
const name = path.basename(canonical);
|
||||||
|
records.push({
|
||||||
|
path: canonical,
|
||||||
|
name,
|
||||||
|
format: path.extname(name).slice(1).toLowerCase(),
|
||||||
|
parentName: path.basename(path.dirname(canonical))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visitFile(value) {
|
||||||
|
const extension = path.extname(value).slice(1).toLowerCase();
|
||||||
|
if (!BOOK_EXT.has(extension)) return;
|
||||||
|
const canonical = await canonicalEntry(value, 'isFile');
|
||||||
|
if (canonical) addFile(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visitDirectory(value) {
|
||||||
|
const canonical = await canonicalEntry(value, 'isDirectory');
|
||||||
|
if (!canonical) return;
|
||||||
|
|
||||||
|
const key = pathKey(canonical);
|
||||||
|
if (visitedDirectories.has(key)) return;
|
||||||
|
visitedDirectories.add(key);
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(canonical, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.sort((left, right) => comparePaths(left.name, right.name));
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const child = path.join(canonical, entry.name);
|
||||||
|
const stat = await safeLstat(child);
|
||||||
|
if (!stat || stat.isSymbolicLink()) continue;
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await visitDirectory(child);
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
await visitFile(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const stat = await safeLstat(candidate);
|
||||||
|
if (!stat || stat.isSymbolicLink()) continue;
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await visitDirectory(candidate);
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
await visitFile(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records.sort((left, right) => comparePaths(left.path, right.path));
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { discover };
|
||||||