Compare commits

..
21 Commits
Author SHA1 Message Date
lofyer c5b9072ffd feat: 发布 v2.1.0 开放书源扩展
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
构建与发布 / 发布 GitHub Release (push) Has been cancelled
构建与发布 / 单测与集成测试 (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
2026-08-06 00:36:20 +08:00
lofyer d406a0a508 docs: 记录 CI 与 Linux 平台差异踩坑
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 20:28:26 +08:00
lofyer 6841cfe38f test: 焦点锚定容差按实际每行字数算,不再写死字符数
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 20:19:08 +08:00
lofyer 8190259ad5 ci: 安装中文与 Liberation 字体,避免豆腐块和版式测量偏差
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 20:08:44 +08:00
lofyer 3f8ae81a14 test: 集成夹具的代理改为读 HTTPS_PROXY,不再写死本机端口
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 20:01:33 +08:00
lofyer cd28a8ad38 ci: 集成测试关闭沙箱,runner 无法给 chrome-sandbox 加 setuid
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 单测与集成测试 (push) Waiting to run
2026-08-05 19:54:01 +08:00
lofyer a09a4413ee fix: 图像清理按整毫秒比对 mtime,Linux 上亚毫秒精度会让年龄变负
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 19:47:59 +08:00
lofyer 30523b4a77 ci: 注解只保留失败条目,避免被通过用例挤掉
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 19:43:26 +08:00
lofyer 7ff101044a ci: 任何失败路径都输出注解
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 19:39:59 +08:00
lofyer 09fae64f1f ci: 测试失败时输出注解,便于无管理员权限时定位
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-05 19:35:57 +08:00
lofyer 4cb7ac7100 docs: 公开源码前移除站点文档里的私有远端地址
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
2026-08-05 19:09:02 +08:00
lofyer 7ca023023e feat: 增加 Linux 打包与 GitHub 构建发布链
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
新增 build-linux.js,直接从官方 zip 转写 tar 保住可执行位,
Windows 上也能构建 Linux 包。新增 build-release.js 作为发布件
唯一出口,排除便携版 data/、回读产物校验内容、生成校验和。
GitHub Actions 分测试、四目标打包、标签发布三段。
2026-08-05 19:07:11 +08:00
lofyer cb7b020dc8 feat: 增加下载任务中心与断点续传 2026-08-05 15:25:26 +08:00
lofyer f72c26642f feat: AI 会话支持按轮保存完整笔记 2026-08-04 20:50:31 +08:00
lofyer 0fd7c59e08 feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读
笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

AI 助手支持多轮会话:会话独立落盘,先取历史再写提问,
历史只发文本不重发图像,失败与取消都保留已流出的残片。

新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线),
补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md,
但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。

书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底,
修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框
去掉衬底色块,恢复原生外观。

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
2026-08-04 16:19:06 +08:00
lofyer 522b0f74a5 feat: 书库支持多选批量整理与移除,修正侧栏选中亮条
工具栏新增「选择」模式,卡片显示复选框,工具条提供全选、批量整理与
批量移除。多选时隐藏单卡操作并停用封面阅读入口,避免勾选途中误开
阅读器。全选只覆盖当前筛选结果,切换书架、标签或搜索后会剔除已不
可见的选中项,不对看不见的书执行批量操作。

批量标签用三态复选框:indeterminate 表示部分书持有,跳过即保持原样,
只有明确勾上或取消才统一应用。沿用单本的覆盖语义会静默抹掉其它书
已有的标签。所选书籍分属不同书架时,下拉默认「保持不变」。

侧栏行内的重命名与删除按钮改为绝对定位。此前它们虽然 opacity 为 0,
仍占据 flex 空间,使书架与标签的选中亮条比「全部书籍」窄 47px。书架
列表原为普通 block 无行间距,现与标签列表共用 .library-filter-list,
两处间距一致。
2026-08-03 17:18:33 +08:00
lofyer 381c07733a feat: 支持 macOS arm64 打包,修复图标未入库
新增 build-mac.js,产出 ad-hoc 签名的 .app 与 DMG。只能在 macOS 上
构建:DMG 依赖 hdiutil,且 Apple Silicon 拒绝执行未签名二进制,改名
与改 plist 后必须用 codesign 重签。

按官方 darwin-arm64 运行时的真实结构处理四处易错点:用 ditto 解压以
保留 framework 符号链接、删除重打包后失效的 ElectronAsarIntegrity、
为改名后的 helper 补上 CFBundleExecutable、签名由内向外且单独签
Squirrel 的 ShipIt。

打包版数据目录改为按平台决定:macOS 走 appData,避免写进 DMG 挂载后
只读的 .app 内部并在升级覆盖时丢失书库;Windows 便携版行为不变。
窗口图标在非 Windows 平台改用 PNG。

.gitignore 的 dist 规则补上前导斜杠。此前它同时匹配 icons/dist/,
导致构建与单测依赖的图标从未入库,新克隆的仓库无法构建。
2026-08-03 16:18:47 +08:00
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 2c5c7b1828 docs: 新增 AGENTS.md,README 不再链接 BUILD.md
公开仓库只发布二进制与使用说明,BUILD.md 仅存在于源码仓库,
README 里改为文字指引,避免公开页面出现失效链接。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 12:47:55 +08:00
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 353f9193c2 docs: 拆出 BUILD.md 存放源码构建与架构说明
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 12:25:39 +08:00
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> efad306312 docs: README 增加多源检索、阅读批注与 AI 助手截图
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 12:19:09 +08:00
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 3ccd044527 feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0
新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 12:13:02 +08:00
380 changed files with 114534 additions and 1350 deletions
+185
View File
@@ -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 指向 npmmirrorGitHub 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 4755SUID 沙箱起不来。
# 只在 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
+7 -1
View File
@@ -1,11 +1,17 @@
node_modules/
dist/
# 必须锚定到仓库根:不加斜杠会连 icons/dist/ 一起忽略,
# 而构建脚本和单测都依赖 icons/dist/ 里的图标产物
/dist/
*.log
.DS_Store
Thumbs.db
# 图标工具生成的对照图,仅供肉眼检查,不参与构建
icons/dist/preview*.png
# 调试探测产生的临时快照
probe*.json
probe-*.js
*.tmp.html
scihub.html
*-diagnostic.png
-4
View File
@@ -1,6 +1,2 @@
registry=https://registry.npmmirror.com
proxy=
https-proxy=
noproxy=*
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+199
View File
@@ -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` 指向 npmmirrorGitHub 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` 检查是否混入密钥。
+244
View File
@@ -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/` 并附带校验和。
### Linuxx64 / 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
```
### macOSApple 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` 指向 npmmirrorGitHub 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
```
+40 -97
View File
@@ -2,15 +2,47 @@
开放获取文献与图书的桌面客户端(Electron)。在一个界面里检索多个公开文献源,查看详情,下载文件并归入本地书库。
## 界面预览
书库:
![PeopleLib 书库界面](docs/screenshots/PeopleLib_bAecy2Izab.png)
多源检索,可选单个数据源或聚合全部:
![PeopleLib 多源检索](docs/screenshots/PeopleLib_34BtvLsqDE.png)
内置阅读器与 PDF 批注:
![PeopleLib 阅读与批注](docs/screenshots/PeopleLib_ScQMUA2D24.png)
AI 助手,可把选中文本、当前页、全文或框选区域作为上下文提问:
![PeopleLib AI 助手](docs/screenshots/PeopleLib_2N6zVpCFBA.png)
## 功能
- **多源检索**12 个数据源统一的搜索、详情、下载流程
- **多源检索**16 个数据源统一的搜索、详情、下载流程
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
- **任务中心**:全局查看下载进度,离开详情页后继续下载,支持暂停、断点续传和删除未完成任务
- **内置阅读器**PDF、EPUB、无 DRM 的 MOBI/KF7/KF8 与 TXT/Markdown 阅读,支持进度、书签、选文和笔记
- **全局代理**:一处配置,对所有数据源与封面请求生效
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
- **版本更新**:手动或启动时检查 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 | 说明 |
@@ -18,6 +50,10 @@
| arXiv 论文 | `arxiv` | 预印本,支持全文检索 |
| Gutenberg 公版书 | `gutenberg` | 公共领域图书 |
| Open Library 图书 | `openlibrary` | 图书元数据与借阅入口 |
| OpenStax 开放教材 | `openstax` | 开放许可教材与 PDF 全文 |
| 开放教材图书馆 | `opentextbook` | 开放教材目录与授权获取入口 |
| 中文维基文库 | `wikisource-zh` | 中文经典与公共领域作品 |
| 英文维基文库 | `wikisource-en` | 英文经典与公共领域作品 |
| DOAJ 开放期刊 | `doaj` | 开放获取期刊论文 |
| PMC 生物医学 | `pmc` | PubMed Central 全文 |
| bioRxiv 预印本 | `biorxiv` | 仅浏览最新列表,不支持关键词搜索 |
@@ -38,104 +74,11 @@
2. 双击目录中的 `PeopleLib.exe`
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`
当前版本为 **1.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
当前版本为 **2.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
## 源码运行与打包
## 开发
源码开发需要 Node.js 18+
```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
```
对主要数据源依次执行搜索、详情、下载链路,输出每一步耗时与结果。
源码运行、打包发布、代理与账号配置、数据位置、项目结构与测试说明见源码仓库中的 `BUILD.md`。本仓库只发布二进制与使用说明。
## 免责声明
+332
View File
@@ -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 图标用 PNG256 供窗口,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;
});
}
+322
View File
@@ -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 图标用 PNG256 供窗口,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;
});
+151 -26
View File
@@ -1,15 +1,86 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const ROOT = __dirname;
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.join(ROOT, 'dist', `PeopleLib-${pkg.version}`);
: path.join(ROOT, 'dist', TARGET);
const OUT = REQUESTED_OUT;
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 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) {
fs.mkdirSync(dst, { recursive: 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);
}
console.log('清理输出目录...');
rimraf(OUT);
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')
);
}
console.log('复制 Electron 运行时...');
copyDir(path.join(ROOT, 'node_modules', 'electron', 'dist'), OUT);
async function build() {
const electronDist = ensureElectronRuntime();
console.log('精简语言包...');
pruneLocales(path.join(OUT, 'locales'));
console.log('清理固定输出目录(保留 data...');
clearOutput(OUT);
console.log('重命名可执行文件...');
fs.renameSync(path.join(OUT, 'electron.exe'), path.join(OUT, PRODUCT + '.exe'));
rimraf(path.join(OUT, 'resources', 'default_app.asar'));
console.log('复制 Electron 运行时...');
copyDir(electronDist, OUT);
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'));
copyUndici(path.join(APP, 'node_modules', 'undici'));
console.log('精简语言包...');
pruneLocales(path.join(OUT, 'locales'));
fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
name: pkg.name, version: pkg.version, description: pkg.description,
main: 'main.js', author: pkg.author, license: pkg.license,
dependencies: { undici: pkg.dependencies.undici }
}, null, 2));
console.log('重命名可执行文件...');
const executable = path.join(OUT, PRODUCT + '.exe');
fs.renameSync(path.join(OUT, 'electron.exe'), executable);
rimraf(path.join(OUT, 'resources', 'default_app.asar'));
console.log('\n构建完成:');
console.log(' 目录:', OUT);
console.log(' 可执行文件:', path.join(OUT, PRODUCT + '.exe'));
console.log(`应用 ${PRODUCT} 图标...`);
const { rcedit } = await import('rcedit');
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;
});
+281
View File
@@ -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;
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+62
View File
@@ -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`);
}
+95
View File
@@ -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()
+1432 -109
View File
File diff suppressed because it is too large Load Diff
+1925 -572
View File
File diff suppressed because it is too large Load Diff
+29 -7
View File
@@ -1,19 +1,37 @@
{
"name": "peoplelib",
"version": "1.1.0",
"description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library",
"version": "2.1.0",
"description": "多源开放文献、电子书与本地书库客户端",
"main": "main.js",
"author": "peoplelib",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
},
"scripts": {
"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": {
"undici": "^6.21.3"
"foliate-js": "1.0.1",
"undici": "8.9.0"
},
"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": {
"appId": "com.peoplelib.client",
@@ -24,10 +42,14 @@
"files": [
"main.js",
"preload.js",
"src/**/*"
"src/**/*",
"icons/dist/*.ico",
"icons/dist/dark/icon-32.png",
"icons/dist/light/icon-32.png"
],
"win": {
"target": "portable"
"target": "portable",
"icon": "icons/dist/book-ai-dark.ico"
},
"portable": {
"artifactName": "PeopleLib-${version}.exe"
+206 -4
View File
@@ -1,5 +1,45 @@
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', {
sources: {
list: () => ipcRenderer.invoke('sources:list'),
@@ -11,21 +51,53 @@ contextBridge.exposeInMainWorld('api', {
library: {
list: () => ipcRenderer.invoke('library:list'),
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),
add: (item) => ipcRenderer.invoke('library:add', item),
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'),
pickDir: () => ipcRenderer.invoke('library:pickDir'),
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'),
onChanged: (cb) => ipcRenderer.on('library:changed', () => cb())
onChanged: (cb) => {
const h = () => cb();
ipcRenderer.on('library:changed', h);
return () => ipcRenderer.removeListener('library:changed', h);
}
},
settings: {
get: (key, def) => ipcRenderer.invoke('settings:get', key, def),
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: {
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
@@ -40,7 +112,137 @@ contextBridge.exposeInMainWorld('api', {
get: () => ipcRenderer.invoke('proxy:get'),
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),
showItem: (p) => ipcRenderer.invoke('shell:showItem', p),
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
View File
+61
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
reader.mesalogo.com
+357
View File
@@ -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 会自己终止 TLSGitHub 那边的 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 出现「跳到主要内容」。
+102
View File
@@ -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() }
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+722
View File
@@ -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;
}
+237
View File
@@ -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>
+330
View File
@@ -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>
+321
View File
@@ -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>
+280
View File
@@ -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.0MIT 许可。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>
+292
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://reader.mesalogo.com/sitemap.xml
+28
View File
@@ -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>
+878
View File
@@ -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');
}
// 最小可解码 JPEGSOI + 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, '原标题');
});
+698
View File
@@ -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 历史进 messagessystem 仍在顶层', 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 历史进 inputinstructions 与 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} 出现空 contentAnthropic 不接受空字符串`);
}
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, /新问题/);
});
+252
View File
@@ -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+sizeforget 不清缓存就会返回过期的 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);
});
+91
View File
@@ -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 });
});
+191
View File
@@ -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);
});
+587
View File
@@ -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',
'![远程图片](https://untrusted.example/tracker.png)'
].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);
});
+287
View File
@@ -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);
});
+588
View File
@@ -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);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+90
View File
@@ -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);
});
+116
View File
@@ -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
};
+128
View File
@@ -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 先解数字实体再解 &amp;,不产生二次解码', () => {
assert.strictEqual(http.decodeEntities('a &amp;lt; b'), 'a &lt; b');
assert.strictEqual(http.decodeEntities('&lt;b&gt;'), '<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();
});
+355
View File
@@ -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);
});
+158
View File
@@ -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 个)/
);
});
+665
View File
@@ -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/ASquirrel 里的 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 指向 npmmirrorGitHub 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\)/);
});
+118
View File
@@ -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';
}),
/页面结构无法识别/
);
});
+96
View File
@@ -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)}`));
});
+269
View File
@@ -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,倍率 21468x1900,远在上限内
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 20000x14000280M),实测不可用
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 24000x18000432M),实测不可用
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,适配器里不该出现新的编码路径'
);
});
+177
View File
@@ -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);
});
+100
View File
@@ -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);
});
File diff suppressed because it is too large Load Diff
+705
View File
@@ -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);
});
+707
View File
@@ -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']);
});
+710
View File
@@ -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>',
'',
'![图注](http://evil.example/tracker.png)',
'',
'[正常外链](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();
}
});
+987
View File
@@ -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(/&#39;/g, "'").replace(/&quot;/g, '"')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}
test('escapeHtml 覆盖全部危险字符', () => {
const { escapeHtml } = loadUtil();
assert.strictEqual(escapeHtml(`<a href="x">&'`), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;');
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('&quot;'), '未做 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 消息的 contextRefquote 取那条问题文本
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\)/);
});
+78
View File
@@ -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 };
+252
View File
@@ -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 };
+148
View File
@@ -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 };

Some files were not shown because too many files have changed in this diff Show More