Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5b9072ffd | ||
|
|
d406a0a508 | ||
|
|
6841cfe38f | ||
|
|
8190259ad5 | ||
|
|
3f8ae81a14 | ||
|
|
cd28a8ad38 | ||
|
|
a09a4413ee | ||
|
|
30523b4a77 | ||
|
|
7ff101044a | ||
|
|
09fae64f1f | ||
|
|
4cb7ac7100 | ||
|
|
7ca023023e | ||
|
|
cb7b020dc8 | ||
|
|
f72c26642f | ||
|
|
0fd7c59e08 | ||
|
|
522b0f74a5 | ||
|
|
381c07733a | ||
|
|
2c5c7b1828 |
@@ -0,0 +1,185 @@
|
|||||||
|
name: 构建与发布
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
# 仓库的 .npmrc 指向 npmmirror,GitHub runner 在境外,走官方源更稳
|
||||||
|
npm_config_registry: https://registry.npmjs.org/
|
||||||
|
ELECTRON_MIRROR: https://github.com/electron/electron/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: 单测与集成测试
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: 校验发布标签与版本号一致
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('标签应为 '+expected+',实际 '+process.env.GITHUB_REF_NAME)}"
|
||||||
|
|
||||||
|
- name: 安装依赖
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# 下载完整日志需要仓库管理员权限。失败详情转成注解,这样没有管理员权限
|
||||||
|
# 的人也能在提交页直接看到是哪条断言挂了,不必去翻日志。
|
||||||
|
- name: 单元测试
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npm test > /tmp/unit.log 2>&1 || {
|
||||||
|
# 注解有长度上限,只挑失败条目与错误细节,别把通过的用例也塞进去
|
||||||
|
grep -E '^(✖|not ok)' /tmp/unit.log | head -20
|
||||||
|
echo "::error title=单元测试失败::$(grep -E '^(✖|not ok)|AssertionError|actual:|expected:|operator:|^\s+at ' /tmp/unit.log \
|
||||||
|
| head -40 | cut -c1-300 | sed 's/%/%25/g; s/\r//g' | awk '{printf "%s%%0A", $0}')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
tail -6 /tmp/unit.log
|
||||||
|
|
||||||
|
# Electron 集成测试要开真实窗口,无头环境靠 xvfb 提供 X server
|
||||||
|
- name: Electron 集成测试
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
annotate() {
|
||||||
|
echo "::error title=$1::$(tail -80 "$2" | sed 's/%/%25/g; s/\r//g' | awk '{printf "%s%%0A", $0}')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
sudo apt-get update > /tmp/apt.log 2>&1 || annotate "apt 更新失败" /tmp/apt.log
|
||||||
|
# runner 自带字体极少。界面文案是中文,缺 CJK 字体会渲染成豆腐块,
|
||||||
|
# 文本版式测量也会跟着偏,断言量到的就不是真实排版。
|
||||||
|
sudo apt-get install -y xvfb libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 \
|
||||||
|
libcups2t64 libgbm1 libasound2t64 libgtk-3-0t64 \
|
||||||
|
fonts-liberation fonts-noto-core fonts-noto-cjk >> /tmp/apt.log 2>&1 \
|
||||||
|
|| annotate "运行库安装失败" /tmp/apt.log
|
||||||
|
fc-cache -f > /dev/null 2>&1 || true
|
||||||
|
# runner 上的 chrome-sandbox 拿不到 root:root 4755,SUID 沙箱起不来。
|
||||||
|
# 只在 CI 关沙箱,不要把这个开关带进构建产物。
|
||||||
|
for suite in startup download cover annotation reader-features library-notes ai-scope; do
|
||||||
|
echo "::group::$suite"
|
||||||
|
xvfb-run -a npx electron --no-sandbox "src/_test/electron/$suite.integration.js" \
|
||||||
|
> "/tmp/$suite.log" 2>&1 || { echo "::endgroup::"; annotate "集成测试失败 $suite" "/tmp/$suite.log"; }
|
||||||
|
tail -3 "/tmp/$suite.log"
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
|
||||||
|
package:
|
||||||
|
name: 打包 ${{ matrix.platform }} ${{ matrix.arch }}
|
||||||
|
needs: validate
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: windows
|
||||||
|
arch: x64
|
||||||
|
runner: windows-2025
|
||||||
|
- platform: macos
|
||||||
|
arch: arm64
|
||||||
|
runner: macos-15
|
||||||
|
- platform: linux
|
||||||
|
arch: x64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- platform: linux
|
||||||
|
arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: 缓存 Electron 运行时
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache
|
||||||
|
key: electron-${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
|
- name: 安装依赖
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 构建并校验发布件
|
||||||
|
run: npm run release -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: 上传发布件
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: peoplelib-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
path: dist/release/${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
if-no-files-found: error
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: 发布 GitHub Release
|
||||||
|
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||||
|
needs: package
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: 核对标签指向当前提交
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
expected="v$(node -p "require('./package.json').version")"
|
||||||
|
test "$GITHUB_REF_NAME" = "$expected"
|
||||||
|
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: 下载各平台发布件
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: peoplelib-*
|
||||||
|
path: dist/release-downloads
|
||||||
|
|
||||||
|
- name: 回验校验和并汇总
|
||||||
|
run: npm run release -- --verify dist/release-downloads
|
||||||
|
|
||||||
|
# 先建草稿再转正式,避免上传中途失败留下一个资产不全的 Release
|
||||||
|
- name: 创建 Release 并上传
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="$GITHUB_REF_NAME"
|
||||||
|
if gh release view "$tag" >/dev/null 2>&1; then
|
||||||
|
gh release edit "$tag" --draft --verify-tag
|
||||||
|
else
|
||||||
|
gh release create "$tag" --draft --verify-tag --generate-notes --title "PeopleLib $tag"
|
||||||
|
fi
|
||||||
|
gh release upload "$tag" dist/release-upload/* --clobber
|
||||||
|
gh release edit "$tag" --draft=false
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
# 必须锚定到仓库根:不加斜杠会连 icons/dist/ 一起忽略,
|
||||||
|
# 而构建脚本和单测都依赖 icons/dist/ 里的图标产物
|
||||||
|
/dist/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# 图标工具生成的对照图,仅供肉眼检查,不参与构建
|
||||||
|
icons/dist/preview*.png
|
||||||
|
|
||||||
# 调试探测产生的临时快照
|
# 调试探测产生的临时快照
|
||||||
probe*.json
|
probe*.json
|
||||||
probe-*.js
|
probe-*.js
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# AGENTS
|
||||||
|
|
||||||
|
面向在本仓库工作的 AI 代理。约定之外的部分按代码里的既有写法照做。
|
||||||
|
|
||||||
|
## 项目性质
|
||||||
|
|
||||||
|
Electron 桌面应用:多源文献检索 + 本地书库 + 内置阅读器(PDF/EPUB/MOBI/AZW)+ 批注笔记 + AI 助手。
|
||||||
|
Windows x64 免安装版,用户数据在程序同级 `data/`。
|
||||||
|
|
||||||
|
## 语言与风格
|
||||||
|
|
||||||
|
- 界面文案、提交信息、注释一律中文;标识符英文。
|
||||||
|
- 默认不写注释。只在原因不显然时写:隐藏约束、易踩的坑、绕过某个具体 bug 的原因。不要复述代码在做什么。
|
||||||
|
- 不用 emoji。散文里避免破折号。
|
||||||
|
- 回复简洁,不主动扩展用户没要求的事。
|
||||||
|
|
||||||
|
## 硬性约束
|
||||||
|
|
||||||
|
### 安全边界不可绕过
|
||||||
|
|
||||||
|
- 渲染层永远不能传任意路径读盘。所有文件访问必须过 `main.js` 的 `resolveReadable()`,它只放行书库中真实登记的条目。
|
||||||
|
- 分段读取会话(`src/reader/range-sessions.js`)与 sender 绑定,句柄不透明,webContents 销毁即回收。新增 IPC 时必须校验 sender。
|
||||||
|
- 所有 `ipcMain.handle` 走 `wrap()`,同步抛出也要变成 `{ ok:false }`,否则渲染层的 `await` 无 catch,界面会永远卡在加载中。
|
||||||
|
- 不要把密钥、token 写进日志或错误信息。AI Key 经 `safeStorage` 加密后落盘。
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
- 前端第三方库全部 vendored 在 `src/ui/vendor/`,版本在 `package.json` 中锁死(不用 `^`)。不要引入新的运行时依赖,除非用户明确要求。
|
||||||
|
- `src/ui/vendor/pdf.worker.range.mjs` 是**手工 patch 过的** PDF.js worker,不要用上游文件覆盖。见下文。
|
||||||
|
|
||||||
|
## PDF 分段读取(易踩坑)
|
||||||
|
|
||||||
|
超大 PDF 不整文件读入内存,走 `reader:rangeOpen/rangeRead/rangeClose`。`reader:bytes` 直接拒绝 PDF。
|
||||||
|
|
||||||
|
worker 选择策略在 `pdf-adapter.mjs`:
|
||||||
|
|
||||||
|
- ≤256 MB:用**官方** worker(`pdf.worker.min.mjs`),即使是 range 模式。
|
||||||
|
- >256 MB:用稀疏 worker(`pdf.worker.range.mjs`)。
|
||||||
|
|
||||||
|
稀疏 worker 的基础缓冲区是空的(`new Uint8Array(0)`),因此**任何对 `stream.bytes.buffer` 直接建视图的代码都会抛 RangeError**。已知踩过的坑:`preEvaluateFont` 里的 `ToUnicode` 哈希,错误表现是字体静默降级成不可见的 `ErrorFont`(文本项数量正常,但画布上没有墨迹)。必须用 `stream.getByteRange(start, end)`。
|
||||||
|
|
||||||
|
改动这个 worker 后,用真实 PDF 对比三条路径的渲染结果(文本项数 + 非白像素数):官方 worker 全量数据 / 官方 worker range / 稀疏 worker range。三者应一致。
|
||||||
|
|
||||||
|
## PDF 渲染画质
|
||||||
|
|
||||||
|
- 底栏「画质」档位(1/2/3)经 `renderOpts` 下发为 `opts.renderQuality`,持久化在 `reader.pdfRenderQuality`。
|
||||||
|
- 生效倍率是 `max(devicePixelRatio, renderQuality)`,**HiDPI 屏上不叠乘**。所以断言时不能写「backing 翻倍」,只能断言 `backing / CSS 宽度 == 期望倍率`;开发机 dpr 常见 1.75,写死 2 会假失败。
|
||||||
|
- 画布尺寸必须过 `clampCanvasSize()`:Chromium 单边上限 16384px、面积上限 268435456px。超限时浏览器**静默给出不可用画布**,整页空白且不报错。大幅面图纸(2000pt 以上)在 scale 5 下必然踩中。
|
||||||
|
- 钳制后 backing 与 CSS 盒子的比例不再等于名义倍率,`pdfPage.render` 的 `transform` 必须用 `fit.scaleX` / `fit.scaleY`(两个方向分别算,`Math.floor` 的损失不同),否则画面错位或只画出一角。
|
||||||
|
- 倍率变化必须走 `epoch++` 整篇重建,只改新渲染的页会让同屏出现清晰度不一致。
|
||||||
|
- 集成测试里量画质前要先把视口滚回目标页:离屏页会被回收成空白占位,量到的是占位画布。画布尺寸在重建时立即变大,墨迹要等重绘完才落上去,断言墨迹必须轮询而不是只等比例。
|
||||||
|
|
||||||
|
## TXT 与 Markdown
|
||||||
|
|
||||||
|
- `text-adapter.mjs` 把 txt/md 转成内存 EPUB 再交给 `epub-adapter` 渲染,定位器 `kind` 为 `txt` / `md`,结构与 epub 同源(`chapter` + `offset`)。新增重排格式时记得同步 `REFLOW_KINDS`。
|
||||||
|
- 格式白名单有六处,改一处不够:`main.js` 的 `READABLE_EXT`、`library/store.js` 与 `library/local-import.js` 的 `BOOK_EXT`、`main.js` 里选文件对话框的 `extensions`,以及**渲染层的两份** `READABLE_RE`(`ui/views/library.js`、`ui/reader/shell.mjs`)。单测有正则锁死并逐项比对渲染层与 `READABLE_EXT` 是否一致。
|
||||||
|
- 渲染层那两份漏掉格式**不会报错**:IPC 照样能打开,但卡片上的「阅读」按钮和封面点击入口静默消失,阅读器的「从书库打开」列表里也少掉这些书。用户看到的现象是"内置阅读器打不开 txt"。集成测试里直接调 `reader.open` 是查不出来的,必须从书库界面点「阅读」。
|
||||||
|
- 书库卡片的「阅读」取的是**第一个可阅读文件**。同一条书目既有 txt 又有 pdf 时,加进白名单后打开的文件会从 pdf 变成 txt,断言里不要写死 PDF 画布。
|
||||||
|
- Markdown 走 markdown-it(`html: false`)+ DOMPurify 双保险。裸 HTML 会被转义成文本,所以**净化断言必须按 DOM 查**(`querySelectorAll('script')`、`on*` 属性、`a[href]` 协议),用 `innerHTML` 匹配 `onerror` 会把转义后的字面量误判成漏网。
|
||||||
|
- 编码探测支持 UTF-8 / UTF-16LE / UTF-16BE(含 BOM)与 GB18030。带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本乱码且用户无从修正。
|
||||||
|
|
||||||
|
## AI 助手
|
||||||
|
|
||||||
|
- 上下文范围:`selection | page | document | page-image | region-image`。
|
||||||
|
- 只有 `selection` 需要选中文本;`page` 和 `document` 不依赖选区。
|
||||||
|
- `document` 无论多短都强制弹确认框,并提示可能超出模型上下文限制。
|
||||||
|
- 正文**完整发送,不做本地截断**。模型窗口够不够由接口自己判断,超限时把报错转成中文提示(`isContextOverflow` / `overflowHint`)。曾经按 `MAX_CHARS = 12000` 挖空中间,实测 40 页文档只发出 8 页,而界面仍显示全文字数,属于静默数据丢失,已移除。`clipContext` 保留给有明确预算上限的场合显式调用。
|
||||||
|
- 界面展示的字数必须等于真正外发的字数。任何"先按上限裁剪再发送"的改动都要同步改 `aiCost` 与确认框文案,否则用户会把答非所问归因于模型。
|
||||||
|
- 图像只用 JPEG 单一编码路径,1600px / 目标 400 KB。**不要加格式回退**(PNG 等),维护成本高于收益;视觉模型按像素图块计费,格式不影响费用。
|
||||||
|
- 三种协议:Anthropic `/messages`、OpenAI Responses `/responses`、兼容 Chat `/chat/completions`。图像负载格式各不相同,改动时三条都要验。
|
||||||
|
|
||||||
|
## AI 多轮会话
|
||||||
|
|
||||||
|
- 会话存在 `reader-ai-sessions/`,一个会话一个 JSON,外加可重建的 `index.json`。不进 `store.js`,避免整本阅读进度被聊天记录带着反复重写。
|
||||||
|
- `ai:run` 里顺序是硬约束:**先 `historyFor()` 再 `appendUser()`**。顺序反了,当前这轮提问会出现在自己的历史里,模型收到两遍同样的问题。
|
||||||
|
- 同一会话禁止并发生成(`ai:run` 里按 `sessionId` 查 `aiRuns`)。两轮同时写一个文件,后完成的那轮会把前一轮的消息覆盖掉。
|
||||||
|
- 消息正文存的是**用户看见的那句提问**(`aiTurnTitle`),整篇正文只在 `contextRef` 里留 scope / 字数 / 哈希。把正文当消息存会让重开后的气泡变成十几万字原文,还会被 `LIMITS.question` 截成一段无意义的残句。
|
||||||
|
- 失败和取消都要落盘,并且要把**已经流出来的残片**一起存(`streamed`)。只写空串的话,界面上明明显示着半截回答,一重开就消失。
|
||||||
|
- 历史只发文本:`stream()` 只取 `role` 与 `text`,图像一律不重发。历史里内联 base64 会让每轮费用随轮数线性上涨。
|
||||||
|
- 落盘失败不能把已经拿到的回答变成请求失败,`settleAiAssistant` 吞掉异常。
|
||||||
|
- 书籍删除、孤立对账都要带上会话(`aiSessions.forgetMany` / `orphanReport`),删完调 `collectAiImages()`。内容寻址的图片没有引用者就永远不会被回收。
|
||||||
|
- 集成测试里断言历史时**不能按固定下标取消息**:本轮提问固定在末尾,中间是历史。原来写死 `messages[1]` 的断言在多轮上线后会取到上一轮,表现为"图像尺寸无效"这种完全无关的报错。
|
||||||
|
|
||||||
|
## 笔记独立窗口
|
||||||
|
|
||||||
|
- `src/reader/note-window.js` 是**单窗口多标签**:一个笔记窗口,一条笔记一个标签,已开则切到该标签。同一条笔记两处编辑时 `reader:updateNote` 是整条覆盖、无版本校验,后保存者会把画布内容整块吃掉,所以「一条笔记只能有一个编辑器」是数据安全约束,不是体验优化。`openTab` 必须先 `tabOf()` 查重。
|
||||||
|
- 阅读器内的笔记模态**保留**,因此「模态 + 独立窗口」仍可能撞车。靠列表按钮避开:已开窗时「编辑」变成「在窗口中编辑」并转为聚焦窗口,不再开模态。
|
||||||
|
- 笔记消失时必须**关掉对应标签**(不是销毁整个窗口):`reader:removeNote` 调 `closeFor`,`library:removeMany` 与 `reader:purgeOrphans` 调 `closeForEntries`。留着标签,它下一次保存会把已删的笔记整条写回去。最后一个标签关掉后窗口才自行退场。
|
||||||
|
- 三处窗口广播(`notifyNotesChanged`、`applyWindowIcons`、`notifyUiThemeChanged`)都要带 `noteWindow.all()`,漏一处笔记窗口就收不到笔记变更或主题切换。单测有正则锁死。
|
||||||
|
- `notes:getOne` 要校验 `noteWindow.ownsNote(event.sender, noteId)`:笔记窗口只能读**自己已开标签**的那几条,否则这个通道就是遍历全部笔记的后门。开窗目标也必须过 `findNote()` 用 `listNotes()` 重新对账。
|
||||||
|
- `notes:tabsChanged`(`setTabs`)只能**收窄**标签集,即只允许 `openNotes.delete`,绝不能 `set`。允许渲染层往里加 ID 等于让它自己扩权:谎报持有某条笔记后 `notes:getOne` 立刻放行,授权集合就形同虚设。新增标签只能走 `open()`,那条路径过 `findNote()` 对账。
|
||||||
|
- 关窗拦截三件套必须配套:`close` 里 `preventDefault()` + `closePending` + 看门狗,渲染层处理完调 `notes:shutdownReady`,**用户取消时必须调 `notes:cancelClose`**。少了取消回报,`closePending` 一直为真会让之后每次点关闭都被当成「正在处理」静默忽略,而看门狗仍会在十秒后把带未保存内容的窗口直接销毁。
|
||||||
|
- 笔记没有自动保存。切换标签只留在内存,LRU 回收前要把未保存内容序列化进 `pendingContent`,否则回收即丢改动。
|
||||||
|
- 脏判定必须比对**序列化后的内容**(`contentKey` 与挂载后取的 `baselineKey`),不能用 `pointerdown`/`keydown` 之类的交互事件:只点选不改字也会被判脏,每个标签关闭时都弹一次无谓的确认。基线要在编辑器 `ready()` 之后取(画布有 version 1→2 归一化,拿磁盘原值当基线会让刚打开就显示已修改),保存成功后基线要跟着前移。
|
||||||
|
- 笔记窗口**不**纳入 `isReaderSender`,不获得 AI 会话等阅读器权限。
|
||||||
|
- PDF 底版草稿按 sender 隔离(`resolveDrafts` / `readDraft`),草稿不能跨窗口交接,笔记窗口必须自己 stage。
|
||||||
|
- 编辑区吃满整窗要逐层 `min-height: 0`,缺一层 flex 子项就被内容顶高、画布溢出窗口。
|
||||||
|
- 笔记本下拉框要 `max-width` + `min-width: 0`。只给 `max-width` 不够:`select` 的 min-content 以最长选项为准,长书名/长笔记本名照样把整行顶宽(实测取消限宽后从 260px 涨到 553px)。
|
||||||
|
- 标题栏只放品牌名「笔记」,不要副标题。`note.html` 用的是 `style.css`,那里的 `.titlebar-left` **不是** flex(只有 `reader.css` 才是),把 `brand-sub` 放成 `.brand` 的同级会掉到下一行,把左侧块顶成 46px 而标题栏只有 44px。要加副标题只能像 `index.html` 那样塞进 `.brand` 内部。
|
||||||
|
- `note.html` 不加载 `reader.css`,所以标签条样式必须在 `note-window.css` 里**自带一份**,也不能用只在 `reader.css` 里定义的变量(如 `--hover-bg-soft`),否则静默失效。
|
||||||
|
- 集成测试四个坑:窗口刚建好时 `getURL()` 还是空串,找窗口必须轮询;断言窗口数量只能数**笔记窗口**,用总窗口数当基线会被阅读器窗口的开关搅乱;笔记页同时有多张卡片,找按钮必须限定在 `.note-card[data-note-id=...]` 内,全局找「编辑」会命中别的卡片;多标签后表单是每标签一份,查询要限定在当前激活的 `.note-tab-view` 内,或直接按 `[data-note-id]` 定位,否则量到的是别的标签。
|
||||||
|
- 删除笔记要走真实 IPC(`reader:removeNote`),它内部已经调了 `closeFor`。测试里再手工补一次 `closeFor` 等于在验证自己造的假路径,还会因为重复处理而看到「标签没关掉」的假失败。
|
||||||
|
|
||||||
|
## 书库卡片封面
|
||||||
|
|
||||||
|
- 封面比例来源不一(内置生成 400x500,书源常见 0.65~0.75,还有方图和横图)。卡片盒子固定 `aspect-ratio: 3/4`,用 `background-size: cover` 会按各自比例裁掉不同的边,观感就是「预览大小不一致」,但**量盒子是量不出问题的**(每张都一样宽高),必须看截图或比对可见的封面内容。
|
||||||
|
- 现在用 `contain` 完整显示封面,留白由 `::before` 里同一张图放大模糊后垫底。注意 **`::before` 会盖在父元素自己的背景之上**,所以清晰的那层必须单独画在 `::after` 里,只调 `z-index` 是压不住父元素背景的;照这个顺序:`::before`(模糊,z-index 0)→ `::after`(清晰,z-index 1)→ `.card-cover > *`(角标文字,z-index 2)。两个伪元素都要 `pointer-events: none`,否则挡掉封面的阅读点击。
|
||||||
|
- 多选复选框不要加 `padding` + 背景色块。13px 的原生复选框套一圈衬底后看起来像加粗了边框,浅色封面上的可见性用 `filter: drop-shadow(...)` 解决。
|
||||||
|
|
||||||
|
## 笔记表单的选择器边界
|
||||||
|
|
||||||
|
`.note-edit-form` 里嵌着画布工具栏(`.canvas-note-root`)与富文本工具栏(`.ql-toolbar`),两者都有 `<select>`。因此 `.note-edit-form select { ... }` 这类**后代选择器会一路灌进工具栏**,必须显式 `:not(.canvas-note-root select):not(.ql-toolbar select)`。
|
||||||
|
|
||||||
|
踩过的坑:表单控件的 `margin-top: 5px` 落到工具栏的粗细/纸张下拉上,分组高度变成 28 与 33 两种,工具栏从一行变两行(42px → 78px)。表现像是「工具栏没横排、需要 flex-wrap 调整」,改 `.canvas-note-tool-group` 的 wrap/shrink 只能把 78px 压到 46px,剩下的 4px 差和分组错位依然在,因为根因不在布局属性而在这条越界的样式。
|
||||||
|
|
||||||
|
诊断方法:量到子项全是 28px 而父分组是 33px 时,不要继续猜 flex 属性,直接遍历 `document.styleSheets` 找出 `el.matches(rule.selectorText)` 的全部规则,越界的那条会立刻现形。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # Node 单测
|
||||||
|
npx electron src/_test/electron/<name>.integration.js # Electron 集成
|
||||||
|
```
|
||||||
|
|
||||||
|
集成套件:`ai-scope`、`reader-features`、`library-notes`、`annotation`、`download`、`cover`、`startup`。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 完成任务前跑单测 + 相关集成套件。
|
||||||
|
- 单测里有对源码的正则断言(锁死关键约定)。改了被断言的代码要同步更新断言,不要为了让测试过而弱化断言。
|
||||||
|
- 集成测试断言"真正离开进程的内容"(真实本地 HTTP 服务收到的 body、真实渲染出的像素),不要 stub 渲染层。
|
||||||
|
- 不要为了测试往生产代码里加 `window.__test` 之类的全局钩子,通过真实 UI 断言。
|
||||||
|
- `library-notes` 偶发失败,重跑确认再判断。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Windows,输出 dist/PeopleLib-windows-x64/
|
||||||
|
npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑
|
||||||
|
npm run build:linux -- --arch x64 # Linux,输出 tar.gz,任意平台可构建
|
||||||
|
npm run release -- --platform linux --arch x64 # 发布件 + 校验和
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
- 输出目录固定,不带版本号。改名会导致 `data/` 被遗留在旧目录。
|
||||||
|
- 构建保留 `data/`,但会清空其余内容。**构建前必须退出该目录下运行中的 `PeopleLib.exe`**,否则清理到一半失败,目录处于不完整状态。
|
||||||
|
- 涉及 `data/` 的操作前后做逐文件哈希比对,确认书库、笔记、批注未被改动。
|
||||||
|
|
||||||
|
macOS(`build-mac.js`,易踩坑):
|
||||||
|
|
||||||
|
- **不能交叉构建**。DMG 需要 `hdiutil`,且 Apple Silicon 内核直接拒绝执行未签名二进制;改名与改 plist 会让 Electron 原始签名失效,必须用 macOS 的 `codesign` 重签(ad-hoc `--sign -` 即可)。
|
||||||
|
- 解压官方 zip 必须用 `ditto`。`Electron Framework.framework` 内有符号链接,用 Node 或 `unzip` 解压会展开成副本,签名随即失效。
|
||||||
|
- 重打包后 `Info.plist` 里的 `ElectronAsarIntegrity` 必须删除,否则启动即报完整性错误。
|
||||||
|
- helper 的 plist **没有** `CFBundleExecutable`,靠 bundle 名推断可执行文件名。重命名 helper 后必须显式补上该字段,否则渲染进程起不来,界面一片空白。
|
||||||
|
- 签名严格由内向外:嵌套可执行文件 → helper → framework → 外层 `.app`。带版本的 framework 签 `Versions/A`;`Squirrel.framework` 的 `Resources/ShipIt` 是独立可执行文件,要单独签。
|
||||||
|
- 数据目录走 `~/Library/Application Support/PeopleLib`,**不要**沿用 Windows 的便携布局:`.app` 在 DMG 里只读,且升级覆盖会删掉用户书库。
|
||||||
|
- `.icns` 由 `npm run icons:icns` 生成,纯 Node 实现(icns 自 10.7 起内嵌 PNG),不依赖 macOS 的 `iconutil`。窗口图标在非 Windows 平台用 PNG,`.ico` 只有 Windows 认。
|
||||||
|
|
||||||
|
Linux(`build-linux.js`):
|
||||||
|
|
||||||
|
- 直接从官方 zip 的条目转写进 tar,**不落地中间目录**。可执行位存在 zip 的 external attributes 里,先解到 NTFS 再打包会全部丢掉,产物解压后主程序和 `chrome-sandbox` 都不可执行。因此这个脚本在 Windows 上也能构建。
|
||||||
|
- tar 头是手写的。路径超过 100 字节要走 PAX 扩展头:ustar 的 `prefix` 只能在斜杠处切分,`undici` 与 vendor 里的深层路径切不出合法组合。
|
||||||
|
|
||||||
|
发布件(`build-release.js`):
|
||||||
|
|
||||||
|
- 发布件的唯一出口,不要手工压缩构建目录上传。Windows 便携版的 `data/` 就在程序同级,手工压缩会把用户书库连同笔记打进公开发布件;脚本按前缀排除并在打包后回读压缩包确认。
|
||||||
|
- 打完包一定回读产物再签校验和:Linux 要确认可执行位还在,Windows 要确认没有 `data/` 与 `_test`。只算哈希不看内容,等于把「构建脚本改坏了」这类问题一路放到用户手上。
|
||||||
|
|
||||||
|
## 持续集成
|
||||||
|
|
||||||
|
- `.github/workflows/build.yml`:`validate`(单测 + 全部集成套件,`xvfb-run` 起 X server)→ `package`(四目标矩阵)→ `release`(仅 `v*` 标签)。
|
||||||
|
- 仓库 `.npmrc` 指向 npmmirror,GitHub runner 在境外拉不动,工作流用 `npm_config_registry` 与 `ELECTRON_MIRROR` 覆盖回官方源。新增构建步骤时别把这两个环境变量漏掉。
|
||||||
|
- 新增集成套件后要同步加进工作流的套件列表,单测里有断言按 `src/_test/electron/` 的实际文件逐个核对,漏加会直接失败。
|
||||||
|
- 标签名必须等于 `v` + `package.json` 的 `version`,且标签要指向被构建的那个提交。
|
||||||
|
- 下载完整日志要仓库管理员权限,所以失败详情都转成 `::error` 注解。改这几步时别把注解去掉,否则没有管理员权限的人只能看到「exit code 1」。
|
||||||
|
- runner 上 `chrome-sandbox` 拿不到 root:root 4755,集成测试必须带 `--no-sandbox`。这个开关只属于 CI,不要带进构建产物。
|
||||||
|
- runner 自带字体极少,工作流装了 Liberation 与 Noto CJK。界面文案是中文,缺 CJK 字体会渲染成豆腐块,版式测量也跟着偏。
|
||||||
|
|
||||||
|
Linux 上首次跑测试暴露过三类只在该平台成立的问题,新写代码时留意:
|
||||||
|
|
||||||
|
- `mtimeMs` 在 ext4/APFS 带亚毫秒小数,`Date.now()` 只到整毫秒。直接相减,刚落盘的文件年龄是负数,「超过宽限期就回收」的逻辑永远不触发。比较前先 `Math.floor`。
|
||||||
|
- 集成夹具要走网络时,代理只能读 `HTTPS_PROXY`,不能写死本机端口,否则 CI 上直接 ECONNREFUSED。
|
||||||
|
- 版式类断言的容差不要写死字符数,按实际渲染出的每行字数算。字体集不同,同一行的字数就不同,写死的数字换个平台就假失败。
|
||||||
|
|
||||||
|
## 仓库
|
||||||
|
|
||||||
|
两个远端,用途不同:
|
||||||
|
|
||||||
|
- `origin`(私有):完整源码,日常开发推这里。
|
||||||
|
- `github`(公开):源码与 CI。GitHub Actions 必须 checkout 到源码才能构建,所以公开仓不再只放 README。历史与本地无共同祖先,推送前先核对两边的差异范围。
|
||||||
|
|
||||||
|
推公开仓前必须确认:没有本地配置、账号凭据、`data/` 内容或诊断产物混进去。公开仓一旦推出去,删提交也留在别人的克隆里。
|
||||||
|
|
||||||
|
其他:
|
||||||
|
|
||||||
|
- 根目录 `dist/` 已 gitignore,规则写作 `/dist/`,**必须保留前导斜杠**:不加会连 `icons/dist/` 一起忽略,新克隆的仓库缺图标,构建直接失败。诊断产物(`*-diagnostic.png`、`probe*.json`)也已忽略,不要提交。
|
||||||
|
- 未跟踪文件视为用户资产,不要删除或覆盖。清理前先看 `git status --porcelain`。
|
||||||
|
- 提交前 `git diff --cached` 检查是否混入密钥。
|
||||||
@@ -19,7 +19,139 @@ npm run portable
|
|||||||
|
|
||||||
输出目录固定为 `dist/PeopleLib-windows-x64/`,不随版本号变化,重复构建会保留其中的 `data/` 目录。构建前需退出该目录下正在运行的 `PeopleLib.exe`,否则会因文件占用而中止。
|
输出目录固定为 `dist/PeopleLib-windows-x64/`,不随版本号变化,重复构建会保留其中的 `data/` 目录。构建前需退出该目录下正在运行的 `PeopleLib.exe`,否则会因文件占用而中止。
|
||||||
|
|
||||||
发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v1.3.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
|
发布件不要手工压缩目录上传,用下面的发布打包入口生成,它会排除 `data/` 并附带校验和。
|
||||||
|
|
||||||
|
### Linux(x64 / arm64)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:linux -- --arch x64
|
||||||
|
npm run build:linux -- --arch arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
产出 `dist/PeopleLib-linux-<arch>.tar.gz`,解压后运行其中的 `PeopleLib.sh`。
|
||||||
|
|
||||||
|
脚本直接把官方 Electron zip 里的条目转写进 tar,不落地中间目录,因此在 Windows 上也能构建出可用的 Linux 包。可执行位存在 zip 的 external attributes 里,先解压到 NTFS 再打包会把这些位全部丢掉,产物解压后 `PeopleLib` 与 `chrome-sandbox` 都不可执行。
|
||||||
|
|
||||||
|
多数发行版开启了非特权用户命名空间,无需额外配置。内核禁用该特性时(`kernel.unprivileged_userns_clone=0`),需给沙箱补 setuid:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown root:root PeopleLib-linux-x64/chrome-sandbox
|
||||||
|
sudo chmod 4755 PeopleLib-linux-x64/chrome-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS(Apple Silicon)
|
||||||
|
|
||||||
|
**只能在 macOS 上执行**,且需要 Xcode 命令行工具(`xcode-select --install`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
产出 `dist/PeopleLib-macos-arm64/PeopleLib.app` 与 `dist/PeopleLib-macos-arm64.dmg`。
|
||||||
|
|
||||||
|
不能在 Windows 或 Linux 上交叉构建,原因有两条,都无法绕开:
|
||||||
|
|
||||||
|
- DMG 由 `hdiutil` 生成,该工具只存在于 macOS。
|
||||||
|
- Apple Silicon 内核会拒绝执行未签名的二进制。打包过程要重命名可执行文件、修改 `Info.plist`,Electron 的原始签名必然失效,必须用 macOS 的 `codesign` 重新签名。
|
||||||
|
|
||||||
|
脚本使用 ad-hoc 签名(`codesign --sign -`),可以在本机及自行放行的机器上运行,但未经 Apple 公证。首次打开需右键点按图标选择「打开」,或执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -dr com.apple.quarantine /Applications/PeopleLib.app
|
||||||
|
```
|
||||||
|
|
||||||
|
图标 `icons/dist/book-ai-*.icns` 已随仓库提供。源 PNG 变更后用 `npm run icons:icns` 重新生成,该脚本在任意平台都能运行,不依赖 macOS 的 `iconutil`。
|
||||||
|
|
||||||
|
## 发布打包
|
||||||
|
|
||||||
|
`build-release.js` 是发布件的唯一出口,负责调用平台构建脚本、校验产物内容、生成校验和:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release -- --platform windows --arch x64
|
||||||
|
npm run release -- --platform macos --arch arm64
|
||||||
|
npm run release -- --platform linux --arch x64
|
||||||
|
npm run release -- --platform linux --arch arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
加 `--skip-build` 可复用已有的构建产物。输出落在 `dist/release/<platform>-<arch>/`,含发布件、`SHA256SUMS.txt` 与 `release-manifest.json`。
|
||||||
|
|
||||||
|
发布前的校验是硬要求,不要跳过直接压缩目录上传:
|
||||||
|
|
||||||
|
- Windows 便携版把用户书库放在程序同级 `data/`,本机构建目录里通常有内容,手工压缩会把整个书库连同笔记打进公开发布件。脚本按前缀排除 `data/`,并在打包后回读压缩包确认。
|
||||||
|
- Linux 产物必须回读 tar 确认 `PeopleLib`、`PeopleLib.sh`、`chrome-sandbox` 带可执行位,丢了就是解压后点不开。
|
||||||
|
- 三个平台都会检查有没有混入 `_test`。
|
||||||
|
|
||||||
|
汇总多平台产物时用回验模式,它逐个比对哈希、体积与版本,再汇总到 `dist/release-upload/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release -- --verify dist/release-downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
## 持续集成
|
||||||
|
|
||||||
|
`.github/workflows/build.yml` 在推送 `main`、提交 PR、打 `v*` 标签和手动触发时运行,分三个阶段:
|
||||||
|
|
||||||
|
1. `validate`:`npm ci` 后跑单测与全部 Electron 集成套件。集成测试要开真实窗口,无头 runner 上用 `xvfb-run` 提供 X server。
|
||||||
|
2. `package`:四个目标并行打包(`windows-2025`、`macos-15`、`ubuntu-24.04`、`ubuntu-24.04-arm`),各自调用 `npm run release`,产物作为 artifact 保留 30 天。
|
||||||
|
3. `release`:仅在推送 `v*` 标签时执行,回验各平台校验和后创建 GitHub Release 并上传。
|
||||||
|
|
||||||
|
两个容易踩的点:
|
||||||
|
|
||||||
|
- 仓库 `.npmrc` 指向 npmmirror,GitHub runner 在境外拉不动,工作流用 `npm_config_registry` 与 `ELECTRON_MIRROR` 覆盖回官方源。
|
||||||
|
- 标签名必须与 `package.json` 的 `version` 一致(`v2.0.0` 对应 `2.0.0`),且标签要指向被构建的那个提交,两处校验不过直接中止发布。
|
||||||
|
|
||||||
|
Release 先建草稿、上传完再转正式,上传中途失败不会在页面上留下一个资产不全的版本。应用根据最新 Release 标签判断是否需要更新。
|
||||||
|
|
||||||
|
## 固定构建变体
|
||||||
|
|
||||||
|
PeopleLib 采用固定构建变体,不使用远程开关在应用发布后改变功能范围:
|
||||||
|
|
||||||
|
| 构建 | 定位 | 功能范围 |
|
||||||
|
|---|---|---|
|
||||||
|
| 桌面完整版 | Windows、macOS | 多源检索、下载与任务中心、数据源账号、代理、本地书库、阅读和笔记 |
|
||||||
|
| 移动阅读版 | iPadOS、Android | 本地导入、书库、阅读、笔记、书签和批注 |
|
||||||
|
|
||||||
|
移动阅读版固定关闭以下能力:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
remoteSearch: false,
|
||||||
|
remoteDownload: false,
|
||||||
|
sourceAccounts: false,
|
||||||
|
proxy: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这些能力必须在构建时确定。移动端不展示相关入口、不注册对应桥接 API、不发起数据源请求,也不能通过服务端配置重新开启。桌面端继续保留完整功能。
|
||||||
|
|
||||||
|
移动端不能直接复用 Electron 包,需要使用独立的移动端外壳。下载、文件系统、安全存储和阅读文件访问均应通过 iPadOS/Android 原生桥接实现。首个移动版本只提供本地阅读能力;从系统文件选择器、分享面板或用户自行管理的云盘导入文件,不提供应用内在线检索和下载。
|
||||||
|
|
||||||
|
移动阅读版必须由专用脚本生成,不能依赖开发者手工删除页面或模块。计划提供以下固定入口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:android
|
||||||
|
npm run build:ios
|
||||||
|
```
|
||||||
|
|
||||||
|
两个命令应调用同一套移动构建脚本,并把平台与固定变体显式传入,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node build-mobile.js --platform android --variant reader-only
|
||||||
|
node build-mobile.js --platform ios --variant reader-only
|
||||||
|
```
|
||||||
|
|
||||||
|
`build-mobile.js` 必须完成:
|
||||||
|
|
||||||
|
1. 校验变体只能是 `reader-only`,拒绝从环境变量或远程配置开启受限能力。
|
||||||
|
2. 生成移动端能力清单,并在编译前固定关闭检索、下载、数据源账号和代理。
|
||||||
|
3. 使用移动端入口组装资源,不注册桌面端 IPC,不复制在线数据源模块。
|
||||||
|
4. 调用 Android 或 iOS 原生工程构建工具,并把产物输出到固定的 `dist/PeopleLib-android/` 或 `dist/PeopleLib-ios/`。
|
||||||
|
5. 对最终产物运行自动化检查,确认不存在检索、下载、数据源账号和代理入口。
|
||||||
|
|
||||||
|
Android 构建可以在 Windows、macOS 或 Linux 上执行;iOS/iPadOS 构建依赖 Xcode、签名与 Apple SDK,只能在 macOS 上执行。
|
||||||
|
|
||||||
|
当前仓库尚未包含 `build-mobile.js` 与移动端原生工程,所以上述命令是必须补齐的目标构建入口,目前不能生成移动安装包。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
@@ -39,8 +171,12 @@ npm run portable
|
|||||||
|
|
||||||
| 模式 | 路径 |
|
| 模式 | 路径 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 开发运行 | `%APPDATA%/PeopleLib`(Windows) |
|
| 开发运行(Windows) | `%APPDATA%/PeopleLib` |
|
||||||
| 打包运行 | 可执行文件同级的 `data/` 目录 |
|
| 开发运行(macOS) | `~/Library/Application Support/PeopleLib` |
|
||||||
|
| 打包运行(Windows 便携版) | 可执行文件同级的 `data/` 目录 |
|
||||||
|
| 打包运行(macOS) | `~/Library/Application Support/PeopleLib` |
|
||||||
|
|
||||||
|
macOS 不采用便携布局:`.app` 内部在 DMG 挂载时只读,且覆盖升级会连同用户书库一并删除。
|
||||||
|
|
||||||
该目录包含:
|
该目录包含:
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ AI 助手,可把选中文本、当前页、全文或框选区域作为上下
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **多源检索**:12 个数据源统一的搜索、详情、下载流程
|
- **多源检索**:16 个数据源统一的搜索、详情、下载流程
|
||||||
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
|
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
|
||||||
- **内置阅读器**:PDF、EPUB 与无 DRM 的 MOBI/KF7/KF8 阅读,支持进度、书签、选文和笔记
|
- **任务中心**:全局查看下载进度,离开详情页后继续下载,支持暂停、断点续传和删除未完成任务
|
||||||
|
- **内置阅读器**:PDF、EPUB、无 DRM 的 MOBI/KF7/KF8 与 TXT/Markdown 阅读,支持进度、书签、选文和笔记
|
||||||
- **全局代理**:一处配置,对所有数据源与封面请求生效
|
- **全局代理**:一处配置,对所有数据源与封面请求生效
|
||||||
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
|
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
|
||||||
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
|
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
|
||||||
@@ -37,7 +38,8 @@ AI 助手,可把选中文本、当前页、全文或框选区域作为上下
|
|||||||
| PDF | ✓ | ✓ | 支持页面批注、书签、选文和笔记 |
|
| PDF | ✓ | ✓ | 支持页面批注、书签、选文和笔记 |
|
||||||
| EPUB | ✓ | ✓ | 支持目录、重排、书签、选文和笔记 |
|
| EPUB | ✓ | ✓ | 支持目录、重排、书签、选文和笔记 |
|
||||||
| MOBI / AZW / AZW3 | ✓ | ✓ | 使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容 |
|
| MOBI / AZW / AZW3 | ✓ | ✓ | 使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容 |
|
||||||
| TXT / DJVU / FB2 / CBZ / CBR | ✓ | — | 可入库、整理并调用系统关联应用打开 |
|
| TXT / MD | ✓ | ✓ | 自动识别编码,Markdown 渲染标题、列表、代码块与表格 |
|
||||||
|
| DJVU / FB2 / CBZ / CBR | ✓ | — | 可入库、整理并调用系统关联应用打开 |
|
||||||
|
|
||||||
DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,可改用系统关联应用打开。
|
DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,可改用系统关联应用打开。
|
||||||
|
|
||||||
@@ -48,6 +50,10 @@ DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不
|
|||||||
| arXiv 论文 | `arxiv` | 预印本,支持全文检索 |
|
| arXiv 论文 | `arxiv` | 预印本,支持全文检索 |
|
||||||
| Gutenberg 公版书 | `gutenberg` | 公共领域图书 |
|
| Gutenberg 公版书 | `gutenberg` | 公共领域图书 |
|
||||||
| Open Library 图书 | `openlibrary` | 图书元数据与借阅入口 |
|
| Open Library 图书 | `openlibrary` | 图书元数据与借阅入口 |
|
||||||
|
| OpenStax 开放教材 | `openstax` | 开放许可教材与 PDF 全文 |
|
||||||
|
| 开放教材图书馆 | `opentextbook` | 开放教材目录与授权获取入口 |
|
||||||
|
| 中文维基文库 | `wikisource-zh` | 中文经典与公共领域作品 |
|
||||||
|
| 英文维基文库 | `wikisource-en` | 英文经典与公共领域作品 |
|
||||||
| DOAJ 开放期刊 | `doaj` | 开放获取期刊论文 |
|
| DOAJ 开放期刊 | `doaj` | 开放获取期刊论文 |
|
||||||
| PMC 生物医学 | `pmc` | PubMed Central 全文 |
|
| PMC 生物医学 | `pmc` | PubMed Central 全文 |
|
||||||
| bioRxiv 预印本 | `biorxiv` | 仅浏览最新列表,不支持关键词搜索 |
|
| bioRxiv 预印本 | `biorxiv` | 仅浏览最新列表,不支持关键词搜索 |
|
||||||
@@ -68,11 +74,11 @@ DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不
|
|||||||
2. 双击目录中的 `PeopleLib.exe`。
|
2. 双击目录中的 `PeopleLib.exe`。
|
||||||
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。
|
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。
|
||||||
|
|
||||||
当前版本为 **1.3.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
|
当前版本为 **2.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
源码运行、打包发布、代理与账号配置、数据位置、项目结构与测试见 [BUILD.md](BUILD.md)。
|
源码运行、打包发布、代理与账号配置、数据位置、项目结构与测试说明见源码仓库中的 `BUILD.md`。本仓库只发布二进制与使用说明。
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// Linux 打包:产出 dist/PeopleLib-linux-<arch>.tar.gz。
|
||||||
|
// 直接把官方 zip 里的条目转写进 tar,不落地中间目录:Linux 的可执行位存在
|
||||||
|
// zip 的 external attributes 里,先解到 NTFS 再打包会把这些位全部丢掉,
|
||||||
|
// 产物解压后 PeopleLib 与 chrome-sandbox 都不可执行。因此本脚本在任意平台
|
||||||
|
// 都能构建 x64 与 arm64 包。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||||
|
const SUPPORTED_ARCHES = ['x64', 'arm64'];
|
||||||
|
|
||||||
|
// 固定时间戳让同一份源码重复构建得到逐字节一致的产物,便于用哈希核对发布件。
|
||||||
|
const MTIME = Number(process.env.SOURCE_DATE_EPOCH) || 1735689600;
|
||||||
|
|
||||||
|
const KEEP_LOCALES = new Set(['zh-CN.pak', 'en-US.pak']);
|
||||||
|
// 无扩展名的可执行文件与 .so 之外,这几个也必须带执行位
|
||||||
|
const FORCE_EXECUTABLE = new Set(['chrome-sandbox', 'chrome_crashpad_handler']);
|
||||||
|
|
||||||
|
function parseArch(argv) {
|
||||||
|
const index = argv.indexOf('--arch');
|
||||||
|
const value = index >= 0 ? argv[index + 1] : process.arch;
|
||||||
|
if (!SUPPORTED_ARCHES.includes(value)) {
|
||||||
|
throw new Error(`不支持的架构:${value || '未指定'}(可选 ${SUPPORTED_ARCHES.join(' / ')})`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd, args) {
|
||||||
|
const result = spawnSync(cmd, args, { stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`${cmd} 失败(退出码 ${result.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方 zip 只在需要时下载一次,缓存在 node_modules/.cache 下按版本和架构分目录
|
||||||
|
function ensureRuntimeZip(arch) {
|
||||||
|
const version = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
|
||||||
|
if (!version) throw new Error('package.json 未锁定 electron 版本');
|
||||||
|
const cacheDir = path.join(ROOT, 'node_modules', '.cache', `electron-linux-${arch}`, version);
|
||||||
|
const zip = path.join(cacheDir, 'electron.zip');
|
||||||
|
if (fs.existsSync(zip) && fs.statSync(zip).size > 0) return zip;
|
||||||
|
|
||||||
|
const mirror = process.env.ELECTRON_MIRROR
|
||||||
|
|| process.env.npm_config_electron_mirror
|
||||||
|
|| 'https://npmmirror.com/mirrors/electron/';
|
||||||
|
const url = `${mirror.replace(/\/?$/, '/')}v${version}/electron-v${version}-linux-${arch}.zip`;
|
||||||
|
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
const partial = `${zip}.download`;
|
||||||
|
fs.rmSync(partial, { force: true });
|
||||||
|
console.log(`下载 Electron ${version} (linux-${arch})...`);
|
||||||
|
run('curl', ['-fSL', '--retry', '3', '-o', partial, url]);
|
||||||
|
fs.renameSync(partial, zip);
|
||||||
|
return zip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutableName(name) {
|
||||||
|
const base = path.posix.basename(name);
|
||||||
|
if (FORCE_EXECUTABLE.has(base)) return true;
|
||||||
|
if (/\.so(\.\d+)*$/.test(base)) return true;
|
||||||
|
return !base.includes('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// jszip 会把 external attributes 的高 16 位解析成 unixPermissions;
|
||||||
|
// 个别条目缺失时按文件名兜底,宁可多给执行位也不能让主程序起不来。
|
||||||
|
function modeOf(entry, name) {
|
||||||
|
const raw = entry.unixPermissions;
|
||||||
|
const parsed = typeof raw === 'number' ? raw & 0o7777 : 0;
|
||||||
|
if (parsed) return parsed;
|
||||||
|
return isExecutableName(name) ? 0o755 : 0o644;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRuntimeEntries(zipPath, arch) {
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
const zip = await JSZip.loadAsync(fs.readFileSync(zipPath));
|
||||||
|
const entries = [];
|
||||||
|
let sawElectron = false;
|
||||||
|
|
||||||
|
for (const name of Object.keys(zip.files)) {
|
||||||
|
const entry = zip.files[name];
|
||||||
|
if (entry.dir) continue;
|
||||||
|
if (name.startsWith('locales/') && !KEEP_LOCALES.has(path.posix.basename(name))) continue;
|
||||||
|
if (name === 'resources/default_app.asar') continue;
|
||||||
|
|
||||||
|
// Electron Linux 包不该有符号链接;真出现了要显式失败,静默展开成副本
|
||||||
|
// 会让产物体积翻倍且行为不可预期。
|
||||||
|
const unixMode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions : 0;
|
||||||
|
if ((unixMode & 0o170000) === 0o120000) {
|
||||||
|
throw new Error(`运行时包含未预期的符号链接:${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = name === 'electron' ? PRODUCT : name;
|
||||||
|
if (name === 'electron') sawElectron = true;
|
||||||
|
entries.push({
|
||||||
|
name: target,
|
||||||
|
mode: name === 'electron' ? 0o755 : modeOf(entry, name),
|
||||||
|
data: await entry.async('nodebuffer')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawElectron) throw new Error(`Electron 运行时缺少主可执行文件(linux-${arch})`);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipDevFiles(e) {
|
||||||
|
const name = e.name.toLowerCase();
|
||||||
|
if (e.isDirectory()) return false;
|
||||||
|
if (/\.(d\.ts|d\.ts\.map|ts|tsx|map|flow)$/.test(name)) return true;
|
||||||
|
if (/^(readme|changelog|history|license|licence|notice|authors|contributing|security)/.test(name)) return true;
|
||||||
|
if (/\.(md|markdown)$/.test(name)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDir(src, prefix, skip) {
|
||||||
|
const out = [];
|
||||||
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
if (skip && skip(e)) continue;
|
||||||
|
const from = path.join(src, e.name);
|
||||||
|
const to = path.posix.join(prefix, e.name);
|
||||||
|
if (e.isDirectory()) out.push(...collectDir(from, to, skip));
|
||||||
|
else out.push({ name: to, mode: 0o644, data: fs.readFileSync(from) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFile(src, name, mode = 0o644) {
|
||||||
|
return { name, mode, data: fs.readFileSync(src) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appEntries() {
|
||||||
|
const app = 'resources/app';
|
||||||
|
const entries = [
|
||||||
|
collectFile(path.join(ROOT, 'main.js'), `${app}/main.js`),
|
||||||
|
collectFile(path.join(ROOT, 'preload.js'), `${app}/preload.js`),
|
||||||
|
...collectDir(path.join(ROOT, 'src'), `${app}/src`, (e) => e.name.startsWith('_test'))
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||||
|
entries.push(collectFile(path.join(ROOT, 'icons', 'dist', name), `${app}/icons/dist/${name}`));
|
||||||
|
}
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
// Linux 的 BrowserWindow 图标用 PNG:256 供窗口,32 供渲染层复用
|
||||||
|
for (const size of [32, 256]) {
|
||||||
|
entries.push(collectFile(
|
||||||
|
path.join(ROOT, 'icons', 'dist', theme, `icon-${size}.png`),
|
||||||
|
`${app}/icons/dist/${theme}/icon-${size}.png`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const undici = path.join(ROOT, 'node_modules', 'undici');
|
||||||
|
for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) {
|
||||||
|
const file = path.join(undici, name);
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
entries.push(collectFile(file, `${app}/node_modules/undici/${name}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push(...collectDir(
|
||||||
|
path.join(undici, 'lib'), `${app}/node_modules/undici/lib`, skipDevFiles
|
||||||
|
));
|
||||||
|
|
||||||
|
const foliate = path.join(ROOT, 'node_modules', 'foliate-js');
|
||||||
|
for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
|
||||||
|
entries.push(collectFile(path.join(foliate, name), `${app}/node_modules/foliate-js/${name}`));
|
||||||
|
}
|
||||||
|
entries.push(collectFile(
|
||||||
|
path.join(foliate, 'vendor', 'fflate.js'),
|
||||||
|
`${app}/node_modules/foliate-js/vendor/fflate.js`
|
||||||
|
));
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name: `${app}/package.json`,
|
||||||
|
mode: 0o644,
|
||||||
|
data: Buffer.from(`${JSON.stringify({
|
||||||
|
name: pkg.name, version: pkg.version, description: pkg.description,
|
||||||
|
productName: PRODUCT,
|
||||||
|
main: 'main.js', author: pkg.author, license: pkg.license,
|
||||||
|
dependencies: {
|
||||||
|
undici: pkg.dependencies.undici,
|
||||||
|
'foliate-js': pkg.dependencies['foliate-js']
|
||||||
|
}
|
||||||
|
}, null, 2)}\n`, 'utf8')
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面集成留给用户自行安装,这里只给出可直接使用的启动脚本与 .desktop 模板。
|
||||||
|
// 不加 --no-sandbox:多数发行版开启了非特权用户命名空间,Electron 能正常起沙箱;
|
||||||
|
// 内核禁用该特性时才需要按 BUILD.md 给 chrome-sandbox 补 setuid。
|
||||||
|
function launcherEntries() {
|
||||||
|
const launcher = `#!/bin/sh
|
||||||
|
set -e
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
exec "$HERE/${PRODUCT}" "$@"
|
||||||
|
`;
|
||||||
|
const desktop = `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=${PRODUCT}
|
||||||
|
Comment=${pkg.description}
|
||||||
|
Exec=${PRODUCT}.sh %U
|
||||||
|
Icon=${PRODUCT.toLowerCase()}
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Viewer;
|
||||||
|
`;
|
||||||
|
return [
|
||||||
|
{ name: `${PRODUCT}.sh`, mode: 0o755, data: Buffer.from(launcher, 'utf8') },
|
||||||
|
{ name: `${PRODUCT}.desktop`, mode: 0o644, data: Buffer.from(desktop, 'utf8') },
|
||||||
|
collectFile(path.join(ROOT, 'icons', 'dist', 'dark', 'icon-256.png'), `${PRODUCT.toLowerCase()}.png`)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function octal(value, length) {
|
||||||
|
// tar 的数字字段是定长八进制串,末位留给 NUL
|
||||||
|
return Buffer.from(value.toString(8).padStart(length - 1, '0') + '\0', 'ascii');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarHeader({ name, mode, size, typeflag }) {
|
||||||
|
const header = Buffer.alloc(512);
|
||||||
|
header.write(name, 0, 100, 'utf8');
|
||||||
|
octal(mode & 0o7777, 8).copy(header, 100);
|
||||||
|
octal(0, 8).copy(header, 108); // uid
|
||||||
|
octal(0, 8).copy(header, 116); // gid
|
||||||
|
octal(size, 12).copy(header, 124);
|
||||||
|
octal(MTIME, 12).copy(header, 136);
|
||||||
|
header.write(' ', 148, 8, 'ascii'); // 计算校验和时该字段视为空格
|
||||||
|
header.write(typeflag, 156, 1, 'ascii');
|
||||||
|
header.write('ustar\0', 257, 6, 'ascii');
|
||||||
|
header.write('00', 263, 2, 'ascii');
|
||||||
|
header.write('root', 265, 32, 'ascii');
|
||||||
|
header.write('root', 297, 32, 'ascii');
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (const byte of header) sum += byte;
|
||||||
|
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padding(size) {
|
||||||
|
const remainder = size % 512;
|
||||||
|
return remainder ? Buffer.alloc(512 - remainder) : Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径超过 100 字节时用 PAX 扩展头承载完整路径。ustar 的 prefix 字段只能在
|
||||||
|
// 斜杠处切分,undici 与 vendor 里的深层路径切不出合法组合,必须走 PAX。
|
||||||
|
function paxRecords(fullName) {
|
||||||
|
const record = (key, value) => {
|
||||||
|
const body = ` ${key}=${value}\n`;
|
||||||
|
let length = Buffer.byteLength(body) + 1;
|
||||||
|
while (Buffer.byteLength(`${length}${body}`) !== length) length += 1;
|
||||||
|
return Buffer.from(`${length}${body}`, 'utf8');
|
||||||
|
};
|
||||||
|
return record('path', fullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarEntry(fullName, mode, data, typeflag = '0') {
|
||||||
|
const chunks = [];
|
||||||
|
const nameBytes = Buffer.byteLength(fullName, 'utf8');
|
||||||
|
|
||||||
|
if (nameBytes > 100) {
|
||||||
|
const records = paxRecords(fullName);
|
||||||
|
chunks.push(tarHeader({
|
||||||
|
name: `PaxHeader/${path.posix.basename(fullName).slice(0, 80)}`,
|
||||||
|
mode: 0o644,
|
||||||
|
size: records.length,
|
||||||
|
typeflag: 'x'
|
||||||
|
}));
|
||||||
|
chunks.push(records, padding(records.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(tarHeader({
|
||||||
|
// 超长路径已由 PAX 头给出,这里的截断名只是给不支持 PAX 的工具看的回退值
|
||||||
|
name: nameBytes > 100 ? fullName.slice(-100) : fullName,
|
||||||
|
mode,
|
||||||
|
size: data.length,
|
||||||
|
typeflag
|
||||||
|
}));
|
||||||
|
chunks.push(data, padding(data.length));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarGz(entries, root, outFile) {
|
||||||
|
const seen = new Set();
|
||||||
|
const chunks = [];
|
||||||
|
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||||
|
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const full = path.posix.join(root, entry.name);
|
||||||
|
if (seen.has(full)) throw new Error(`打包条目重复:${full}`);
|
||||||
|
seen.add(full);
|
||||||
|
chunks.push(tarEntry(full, entry.mode, entry.data));
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.alloc(1024)); // tar 以两个空块收尾
|
||||||
|
|
||||||
|
const gz = zlib.gzipSync(Buffer.concat(chunks), { level: 9, mtime: 0 });
|
||||||
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||||
|
fs.writeFileSync(outFile, gz);
|
||||||
|
return gz.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
const arch = parseArch(process.argv.slice(2));
|
||||||
|
const target = `${PRODUCT}-linux-${arch}`;
|
||||||
|
const outFile = path.join(ROOT, 'dist', `${target}.tar.gz`);
|
||||||
|
|
||||||
|
const zip = ensureRuntimeZip(arch);
|
||||||
|
console.log('读取 Electron 运行时(保留可执行位)...');
|
||||||
|
const runtime = await readRuntimeEntries(zip, arch);
|
||||||
|
|
||||||
|
console.log('组装 app 源码...');
|
||||||
|
const entries = [...runtime, ...appEntries(), ...launcherEntries()];
|
||||||
|
|
||||||
|
console.log('生成 tar.gz...');
|
||||||
|
const size = writeTarGz(entries, target, outFile);
|
||||||
|
|
||||||
|
console.log('\n构建完成:');
|
||||||
|
console.log(' 架构:', arch);
|
||||||
|
console.log(' 产物:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`);
|
||||||
|
console.log(' 解压后运行:', `./${target}/${PRODUCT}.sh`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseArch, isExecutableName, modeOf, tarEntry, writeTarGz };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
build().catch((error) => {
|
||||||
|
console.error('构建失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// macOS arm64 打包:产出 PeopleLib.app 与 DMG。
|
||||||
|
// 必须在 macOS 上运行:DMG 由 hdiutil 生成,且 Apple Silicon 内核会拒绝执行
|
||||||
|
// 未签名的二进制,改名和塞文件都会让 Electron 原始签名失效,必须重新签。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||||
|
const APP_ID = 'com.peoplelib.client';
|
||||||
|
const ARCH = 'arm64';
|
||||||
|
const TARGET = `${PRODUCT}-macos-${ARCH}`;
|
||||||
|
const OUT = path.join(ROOT, 'dist', TARGET);
|
||||||
|
const APP_BUNDLE = path.join(OUT, `${PRODUCT}.app`);
|
||||||
|
const CONTENTS = path.join(APP_BUNDLE, 'Contents');
|
||||||
|
const RESOURCES = path.join(CONTENTS, 'Resources');
|
||||||
|
const APP = path.join(RESOURCES, 'app');
|
||||||
|
const DMG = path.join(ROOT, 'dist', `${TARGET}.dmg`);
|
||||||
|
|
||||||
|
const KEEP_LOCALES = new Set(['zh_CN', 'en', 'en_GB', 'zh_TW']);
|
||||||
|
|
||||||
|
function run(cmd, args, options) {
|
||||||
|
const result = spawnSync(cmd, args, { stdio: 'inherit', ...options });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`${cmd} 失败(退出码 ${result.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(cmd, args) {
|
||||||
|
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
return { status: result.status, out: `${result.stdout || ''}${result.stderr || ''}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireMac() {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
throw new Error(
|
||||||
|
'macOS 打包只能在 macOS 上执行:DMG 需要 hdiutil,且 Apple Silicon 要求重新签名(codesign)。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const tool of ['hdiutil', 'codesign', 'ditto']) {
|
||||||
|
if (capture('which', [tool]).status !== 0) throw new Error(`缺少 ${tool},请先安装 Xcode 命令行工具`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
|
||||||
|
|
||||||
|
function copyDir(src, dst, skip) {
|
||||||
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
if (skip && skip(e)) continue;
|
||||||
|
const s = path.join(src, e.name);
|
||||||
|
const d = path.join(dst, e.name);
|
||||||
|
if (e.isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(s), d);
|
||||||
|
else if (e.isDirectory()) copyDir(s, d, skip);
|
||||||
|
else fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipDevFiles(e) {
|
||||||
|
const name = e.name.toLowerCase();
|
||||||
|
if (e.isDirectory()) return false;
|
||||||
|
if (/\.(d\.ts|d\.ts\.map|ts|tsx|map|flow)$/.test(name)) return true;
|
||||||
|
if (/^(readme|changelog|history|license|licence|notice|authors|contributing|security)/.test(name)) return true;
|
||||||
|
if (/\.(md|markdown)$/.test(name)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUndici(dst) {
|
||||||
|
const src = path.join(ROOT, 'node_modules', 'undici');
|
||||||
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
|
for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) {
|
||||||
|
const file = path.join(src, name);
|
||||||
|
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dst, name));
|
||||||
|
}
|
||||||
|
copyDir(path.join(src, 'lib'), path.join(dst, 'lib'), skipDevFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFoliate(dst) {
|
||||||
|
const src = path.join(ROOT, 'node_modules', 'foliate-js');
|
||||||
|
fs.mkdirSync(path.join(dst, 'vendor'), { recursive: true });
|
||||||
|
for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
|
||||||
|
fs.copyFileSync(path.join(src, name), path.join(dst, name));
|
||||||
|
}
|
||||||
|
fs.copyFileSync(path.join(src, 'vendor', 'fflate.js'), path.join(dst, 'vendor', 'fflate.js'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方 zip 里 Electron Framework 带符号链接,必须用 ditto 解压;
|
||||||
|
// unzip 或 Node 解压会把链接展开成副本,签名随即失效。
|
||||||
|
function ensureRuntime() {
|
||||||
|
const version = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
|
||||||
|
if (!version) throw new Error('package.json 未锁定 electron 版本');
|
||||||
|
const cacheDir = path.join(ROOT, 'node_modules', '.cache', 'electron-darwin-arm64', version);
|
||||||
|
const source = path.join(cacheDir, 'Electron.app');
|
||||||
|
if (fs.existsSync(path.join(source, 'Contents', 'MacOS', 'Electron'))) return source;
|
||||||
|
|
||||||
|
const mirror = process.env.ELECTRON_MIRROR
|
||||||
|
|| process.env.npm_config_electron_mirror
|
||||||
|
|| 'https://npmmirror.com/mirrors/electron/';
|
||||||
|
const url = `${mirror.replace(/\/?$/, '/')}v${version}/electron-v${version}-darwin-${ARCH}.zip`;
|
||||||
|
const zip = path.join(cacheDir, 'electron.zip');
|
||||||
|
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
console.log(`下载 Electron ${version} (darwin-${ARCH})...`);
|
||||||
|
run('curl', ['-fSL', '--retry', '3', '-o', zip, url]);
|
||||||
|
console.log('解压运行时(ditto 保留符号链接与权限)...');
|
||||||
|
run('ditto', ['-x', '-k', zip, cacheDir]);
|
||||||
|
fs.rmSync(zip, { force: true });
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(source, 'Contents', 'MacOS', 'Electron'))) {
|
||||||
|
throw new Error('Electron 运行时解压结果无效');
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneLproj(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
if (!name.endsWith('.lproj')) continue;
|
||||||
|
if (KEEP_LOCALES.has(name.slice(0, -6))) continue;
|
||||||
|
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plistString(key, value) {
|
||||||
|
return `\t<key>${key}</key>\n\t<string>${value}</string>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInfoPlist() {
|
||||||
|
const file = path.join(CONTENTS, 'Info.plist');
|
||||||
|
let plist = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
const replacements = {
|
||||||
|
CFBundleDisplayName: PRODUCT,
|
||||||
|
CFBundleExecutable: PRODUCT,
|
||||||
|
CFBundleName: PRODUCT,
|
||||||
|
CFBundleIdentifier: APP_ID,
|
||||||
|
CFBundleIconFile: 'app.icns',
|
||||||
|
CFBundleShortVersionString: pkg.version,
|
||||||
|
CFBundleVersion: pkg.version,
|
||||||
|
LSApplicationCategoryType: 'public.app-category.productivity'
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(replacements)) {
|
||||||
|
const re = new RegExp(`\\t<key>${key}</key>\\n\\t<string>[^<]*</string>`);
|
||||||
|
if (!re.test(plist)) throw new Error(`Info.plist 缺少 ${key}`);
|
||||||
|
plist = plist.replace(re, plistString(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重打包后 asar 哈希不再匹配,保留该字段会让 Electron 启动即报完整性错误。
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>ElectronAsarIntegrity<\/key>\n\t<dict>[\s\S]*?\n\t<\/dict>\n/,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, plist);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHelperPlists() {
|
||||||
|
const frameworks = path.join(CONTENTS, 'Frameworks');
|
||||||
|
for (const entry of fs.readdirSync(frameworks)) {
|
||||||
|
if (!entry.endsWith('.app')) continue;
|
||||||
|
const suffix = /\(([^)]+)\)/.exec(entry);
|
||||||
|
const label = suffix ? ` (${suffix[1]})` : '';
|
||||||
|
const oldName = entry.slice(0, -4);
|
||||||
|
const newName = `${PRODUCT} Helper${label}`;
|
||||||
|
const appDir = path.join(frameworks, entry);
|
||||||
|
const file = path.join(appDir, 'Contents', 'Info.plist');
|
||||||
|
|
||||||
|
let plist = fs.readFileSync(file, 'utf8');
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>CFBundleIdentifier<\/key>\n\t<string>[^<]*<\/string>/,
|
||||||
|
plistString('CFBundleIdentifier', `${APP_ID}.helper`)
|
||||||
|
);
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>CFBundleName<\/key>\n\t<string>[^<]*<\/string>/,
|
||||||
|
plistString('CFBundleName', newName)
|
||||||
|
);
|
||||||
|
// 官方 helper plist 没有 CFBundleExecutable,靠 bundle 名推断可执行文件名,
|
||||||
|
// 改名后必须显式写死,否则 helper 进程起不来,界面一片空白。
|
||||||
|
if (!/CFBundleExecutable/.test(plist)) {
|
||||||
|
plist = plist.replace(
|
||||||
|
/\t<key>CFBundleIdentifier<\/key>/,
|
||||||
|
`${plistString('CFBundleExecutable', newName)}\n\t<key>CFBundleIdentifier</key>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, plist);
|
||||||
|
|
||||||
|
fs.renameSync(
|
||||||
|
path.join(appDir, 'Contents', 'MacOS', oldName),
|
||||||
|
path.join(appDir, 'Contents', 'MacOS', newName)
|
||||||
|
);
|
||||||
|
fs.renameSync(appDir, path.join(frameworks, `${newName}.app`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须由内向外签:嵌套可执行文件 -> helper -> framework -> 外层 .app。
|
||||||
|
// 顺序反了会让外层签名立刻失效,Apple Silicon 上表现为应用被内核直接杀掉。
|
||||||
|
function signBundle() {
|
||||||
|
const frameworks = path.join(CONTENTS, 'Frameworks');
|
||||||
|
const targets = [];
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(frameworks)) {
|
||||||
|
const full = path.join(frameworks, entry);
|
||||||
|
if (entry.endsWith('.app')) {
|
||||||
|
targets.push(path.join(full, 'Contents', 'MacOS', entry.slice(0, -4)));
|
||||||
|
targets.push(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.endsWith('.framework')) continue;
|
||||||
|
|
||||||
|
// Squirrel 在 Resources 里藏了独立的 ShipIt 可执行文件,
|
||||||
|
// 不单独签会让 --deep --strict 校验失败。
|
||||||
|
const shipIt = path.join(full, 'Versions', 'A', 'Resources', 'ShipIt');
|
||||||
|
if (fs.existsSync(shipIt)) targets.push(shipIt);
|
||||||
|
|
||||||
|
// 带版本的 framework 要签 Versions/A,直接签 .framework 顶层可能被判定
|
||||||
|
// 为 bundle format unrecognized。
|
||||||
|
const versioned = path.join(full, 'Versions', 'A');
|
||||||
|
targets.push(fs.existsSync(versioned) ? versioned : full);
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.push(APP_BUNDLE);
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
run('codesign', ['--force', '--sign', '-', '--timestamp=none', target]);
|
||||||
|
}
|
||||||
|
run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', APP_BUNDLE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDmg() {
|
||||||
|
rimraf(DMG);
|
||||||
|
const staging = path.join(ROOT, 'dist', `${TARGET}-dmg`);
|
||||||
|
rimraf(staging);
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
run('ditto', [APP_BUNDLE, path.join(staging, `${PRODUCT}.app`)]);
|
||||||
|
fs.symlinkSync('/Applications', path.join(staging, 'Applications'));
|
||||||
|
|
||||||
|
run('hdiutil', [
|
||||||
|
'create', '-volname', `${PRODUCT} ${pkg.version}`,
|
||||||
|
'-srcfolder', staging, '-ov', '-format', 'UDZO', '-fs', 'HFS+', DMG
|
||||||
|
]);
|
||||||
|
rimraf(staging);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
requireMac();
|
||||||
|
const runtime = ensureRuntime();
|
||||||
|
|
||||||
|
console.log('清理输出目录...');
|
||||||
|
rimraf(OUT);
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
console.log('复制 Electron.app...');
|
||||||
|
run('ditto', [runtime, APP_BUNDLE]);
|
||||||
|
|
||||||
|
console.log('精简语言包...');
|
||||||
|
pruneLproj(RESOURCES);
|
||||||
|
pruneLproj(path.join(
|
||||||
|
CONTENTS, 'Frameworks', 'Electron Framework.framework', 'Versions', 'A', 'Resources'
|
||||||
|
));
|
||||||
|
|
||||||
|
console.log('应用图标与 Info.plist...');
|
||||||
|
fs.rmSync(path.join(RESOURCES, 'electron.icns'), { force: true });
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'icons', 'dist', 'book-ai-dark.icns'), path.join(RESOURCES, 'app.icns'));
|
||||||
|
writeInfoPlist();
|
||||||
|
writeHelperPlists();
|
||||||
|
|
||||||
|
console.log('重命名主可执行文件...');
|
||||||
|
fs.renameSync(path.join(CONTENTS, 'MacOS', 'Electron'), path.join(CONTENTS, 'MacOS', PRODUCT));
|
||||||
|
fs.rmSync(path.join(RESOURCES, 'default_app.asar'), { force: true });
|
||||||
|
|
||||||
|
console.log('组装 app 源码...');
|
||||||
|
fs.mkdirSync(APP, { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
|
||||||
|
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
|
||||||
|
const iconDir = path.join(APP, 'icons', 'dist');
|
||||||
|
fs.mkdirSync(iconDir, { recursive: true });
|
||||||
|
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'icons', 'dist', name), path.join(iconDir, name));
|
||||||
|
}
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const themeDir = path.join(iconDir, theme);
|
||||||
|
fs.mkdirSync(themeDir, { recursive: true });
|
||||||
|
// macOS 的 BrowserWindow 图标用 PNG;256 供窗口,32 供渲染层复用
|
||||||
|
for (const size of [32, 256]) {
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(ROOT, 'icons', 'dist', theme, `icon-${size}.png`),
|
||||||
|
path.join(themeDir, `icon-${size}.png`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copyUndici(path.join(APP, 'node_modules', 'undici'));
|
||||||
|
copyFoliate(path.join(APP, 'node_modules', 'foliate-js'));
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
|
||||||
|
name: pkg.name, version: pkg.version, description: pkg.description,
|
||||||
|
productName: PRODUCT,
|
||||||
|
main: 'main.js', author: pkg.author, license: pkg.license,
|
||||||
|
dependencies: {
|
||||||
|
undici: pkg.dependencies.undici,
|
||||||
|
'foliate-js': pkg.dependencies['foliate-js']
|
||||||
|
}
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
console.log('Ad-hoc 签名...');
|
||||||
|
signBundle();
|
||||||
|
|
||||||
|
console.log('生成 DMG...');
|
||||||
|
buildDmg();
|
||||||
|
|
||||||
|
console.log('\n构建完成:');
|
||||||
|
console.log(' 应用:', APP_BUNDLE);
|
||||||
|
console.log(' DMG :', DMG, `(${(fs.statSync(DMG).size / 1024 / 1024).toFixed(1)} MB)`);
|
||||||
|
console.log('\n首次打开:右键点按图标选“打开”,或执行');
|
||||||
|
console.log(` xattr -dr com.apple.quarantine "/Applications/${PRODUCT}.app"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch((error) => {
|
||||||
|
console.error('构建失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// 发布打包:调用各平台构建脚本,把产物收敛成带校验和的发布件。
|
||||||
|
//
|
||||||
|
// node build-release.js --platform windows|macos|linux --arch x64|arm64 [--skip-build]
|
||||||
|
// node build-release.js --verify <目录> # 回验下载下来的各平台发布件
|
||||||
|
//
|
||||||
|
// 产物统一落在 dist/release/<platform>-<arch>/,含发布件本体、SHA256SUMS.txt
|
||||||
|
// 与 release-manifest.json。发布流程只上传这个目录,避免把构建中间物或
|
||||||
|
// 本地 data/ 误传到 Release。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const DIST = path.join(ROOT, 'dist');
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||||
|
const VERSION = pkg.version;
|
||||||
|
|
||||||
|
const TARGETS = {
|
||||||
|
'windows-x64': {
|
||||||
|
build: ['build-portable.js'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-windows-x64`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-windows-x64.zip`,
|
||||||
|
pack: packWindowsZip,
|
||||||
|
verify: verifyWindowsZip
|
||||||
|
},
|
||||||
|
'macos-arm64': {
|
||||||
|
build: ['build-mac.js'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-macos-arm64.dmg`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-macos-arm64.dmg`,
|
||||||
|
pack: copyAsset,
|
||||||
|
verify: verifyDmg
|
||||||
|
},
|
||||||
|
'linux-x64': {
|
||||||
|
build: ['build-linux.js', '--arch', 'x64'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-linux-x64.tar.gz`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-linux-x64.tar.gz`,
|
||||||
|
pack: copyAsset,
|
||||||
|
verify: verifyLinuxTarball
|
||||||
|
},
|
||||||
|
'linux-arm64': {
|
||||||
|
build: ['build-linux.js', '--arch', 'arm64'],
|
||||||
|
source: path.join(DIST, `${PRODUCT}-linux-arm64.tar.gz`),
|
||||||
|
asset: `${PRODUCT}-${VERSION}-linux-arm64.tar.gz`,
|
||||||
|
pack: copyAsset,
|
||||||
|
verify: verifyLinuxTarball
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { skipBuild: false };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const key = argv[i];
|
||||||
|
if (key === '--skip-build') args.skipBuild = true;
|
||||||
|
else if (key === '--platform') args.platform = argv[++i];
|
||||||
|
else if (key === '--arch') args.arch = argv[++i];
|
||||||
|
else if (key === '--verify') args.verify = argv[++i];
|
||||||
|
else throw new Error(`无法识别的参数:${key}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(file) {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
|
||||||
|
|
||||||
|
function runBuild(script) {
|
||||||
|
const result = spawnSync(process.execPath, script, { cwd: ROOT, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`${script[0]} 失败(退出码 ${result.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, prefix = '') {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||||
|
const abs = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...walk(abs, rel));
|
||||||
|
else out.push({ rel, abs });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 便携版把用户书库放在程序同级 data/,本机构建目录里通常是有内容的。
|
||||||
|
// 漏掉这条排除就会把整个书库连同笔记打进公开发布件。
|
||||||
|
function packWindowsZip(source, outFile) {
|
||||||
|
const files = walk(source).filter((f) => !f.rel.startsWith('data/'));
|
||||||
|
if (!files.some((f) => f.rel === `${PRODUCT}.exe`)) {
|
||||||
|
throw new Error(`构建目录缺少 ${PRODUCT}.exe`);
|
||||||
|
}
|
||||||
|
const leaked = files.find((f) => /(^|\/)_test\//.test(f.rel));
|
||||||
|
if (leaked) throw new Error(`构建目录混入测试文件:${leaked.rel}`);
|
||||||
|
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
const zip = new JSZip();
|
||||||
|
const root = `${PRODUCT}-${VERSION}-windows-x64`;
|
||||||
|
for (const file of files.sort((a, b) => (a.rel < b.rel ? -1 : 1))) {
|
||||||
|
zip.file(`${root}/${file.rel}`, fs.readFileSync(file.abs), { date: new Date(0) });
|
||||||
|
}
|
||||||
|
return zip
|
||||||
|
.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } })
|
||||||
|
.then((buffer) => { fs.writeFileSync(outFile, buffer); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAsset(source, outFile) {
|
||||||
|
fs.copyFileSync(source, outFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyWindowsZip(file) {
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
const zip = await JSZip.loadAsync(fs.readFileSync(file));
|
||||||
|
const names = Object.keys(zip.files).map((n) => n.replace(/^[^/]+\//, ''));
|
||||||
|
requireEntries(names, [`${PRODUCT}.exe`, 'resources/app/main.js', 'locales/zh-CN.pak']);
|
||||||
|
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyDmg(file) {
|
||||||
|
const magic = Buffer.alloc(2);
|
||||||
|
const fd = fs.openSync(file, 'r');
|
||||||
|
try { fs.readSync(fd, magic, 0, 2, 0); } finally { fs.closeSync(fd); }
|
||||||
|
// UDZO 镜像是 zlib 压缩块,头两字节为 zlib 魔数;空壳或半截文件在这里就暴露
|
||||||
|
if (magic[0] !== 0x78) throw new Error('DMG 内容无效');
|
||||||
|
if (fs.statSync(file).size < 50 * 1024 * 1024) throw new Error('DMG 体积异常偏小');
|
||||||
|
}
|
||||||
|
|
||||||
|
// tar 里的可执行位是 Linux 产物能不能启动的唯一依据,必须解包回读确认。
|
||||||
|
async function verifyLinuxTarball(file) {
|
||||||
|
const entries = readTarEntries(zlib.gunzipSync(fs.readFileSync(file)));
|
||||||
|
const names = entries.map((e) => e.name.replace(/^[^/]+\//, ''));
|
||||||
|
requireEntries(names, [PRODUCT, `${PRODUCT}.sh`, 'resources/app/main.js', 'locales/zh-CN.pak']);
|
||||||
|
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
|
||||||
|
|
||||||
|
for (const required of [PRODUCT, `${PRODUCT}.sh`, 'chrome-sandbox']) {
|
||||||
|
const entry = entries.find((e) => e.name.replace(/^[^/]+\//, '') === required);
|
||||||
|
if (!entry) throw new Error(`发布件缺少 ${required}`);
|
||||||
|
if (!(entry.mode & 0o111)) throw new Error(`${required} 缺少可执行位`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEntries(names, required) {
|
||||||
|
for (const name of required) {
|
||||||
|
if (!names.includes(name)) throw new Error(`发布件缺少 ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forbidEntries(names, patterns) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const hit = names.find((name) => pattern.test(name));
|
||||||
|
if (hit) throw new Error(`发布件混入不该发布的内容:${hit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTarEntries(buffer) {
|
||||||
|
const entries = [];
|
||||||
|
let pending = '';
|
||||||
|
for (let offset = 0; offset + 512 <= buffer.length;) {
|
||||||
|
const header = buffer.subarray(offset, offset + 512);
|
||||||
|
if (header.every((b) => b === 0)) break;
|
||||||
|
|
||||||
|
const readField = (start, length) => header
|
||||||
|
.toString('ascii', start, start + length).replace(/\0.*$/, '').trim();
|
||||||
|
const size = parseInt(readField(124, 12) || '0', 8);
|
||||||
|
const typeflag = header.toString('ascii', 156, 157);
|
||||||
|
const body = offset + 512;
|
||||||
|
const advance = 512 + Math.ceil(size / 512) * 512;
|
||||||
|
|
||||||
|
if (typeflag === 'x') {
|
||||||
|
const record = /(?:^|\n)\d+ path=([^\n]*)/.exec(buffer.toString('utf8', body, body + size));
|
||||||
|
pending = record ? record[1] : '';
|
||||||
|
} else {
|
||||||
|
entries.push({
|
||||||
|
name: pending || readField(0, 100),
|
||||||
|
mode: parseInt(readField(100, 8) || '0', 8),
|
||||||
|
size
|
||||||
|
});
|
||||||
|
pending = '';
|
||||||
|
}
|
||||||
|
offset += advance;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function packRelease(args) {
|
||||||
|
const arch = args.arch || (args.platform === 'macos' ? 'arm64' : 'x64');
|
||||||
|
const key = `${args.platform}-${arch}`;
|
||||||
|
const target = TARGETS[key];
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`不支持的目标:${key}(可选 ${Object.keys(TARGETS).join(' / ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.skipBuild) runBuild(target.build);
|
||||||
|
if (!fs.existsSync(target.source)) {
|
||||||
|
throw new Error(`构建产物不存在:${target.source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(DIST, 'release', key);
|
||||||
|
rimraf(outDir);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const outFile = path.join(outDir, target.asset);
|
||||||
|
console.log(`打包发布件 ${target.asset} ...`);
|
||||||
|
await target.pack(target.source, outFile);
|
||||||
|
|
||||||
|
console.log('校验发布件内容...');
|
||||||
|
await target.verify(outFile);
|
||||||
|
|
||||||
|
const digest = sha256(outFile);
|
||||||
|
const size = fs.statSync(outFile).size;
|
||||||
|
fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${digest} ${target.asset}\n`);
|
||||||
|
fs.writeFileSync(path.join(outDir, 'release-manifest.json'), `${JSON.stringify({
|
||||||
|
product: PRODUCT,
|
||||||
|
version: VERSION,
|
||||||
|
platform: args.platform,
|
||||||
|
arch,
|
||||||
|
commit: process.env.GITHUB_SHA || null,
|
||||||
|
files: [{ name: target.asset, size, sha256: digest }]
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log('\n发布件就绪:');
|
||||||
|
console.log(' 目标:', key);
|
||||||
|
console.log(' 文件:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`);
|
||||||
|
console.log(' 校验:', digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布任务把各平台 artifact 下载到一处,这里逐个回验哈希再汇总,
|
||||||
|
// 防止把传输中损坏或版本不一致的产物挂到 Release 上。
|
||||||
|
function verifyDownloads(inputDir) {
|
||||||
|
const root = path.resolve(inputDir);
|
||||||
|
if (!fs.existsSync(root)) throw new Error(`目录不存在:${root}`);
|
||||||
|
|
||||||
|
const manifests = walk(root)
|
||||||
|
.filter((f) => path.basename(f.rel) === 'release-manifest.json')
|
||||||
|
.sort((a, b) => (a.rel < b.rel ? -1 : 1));
|
||||||
|
if (!manifests.length) throw new Error('没有找到任何 release-manifest.json');
|
||||||
|
|
||||||
|
const outDir = path.join(DIST, 'release-upload');
|
||||||
|
rimraf(outDir);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const entry of manifests) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(entry.abs, 'utf8'));
|
||||||
|
if (manifest.version !== VERSION) {
|
||||||
|
throw new Error(`${entry.rel} 的版本 ${manifest.version} 与 package.json 的 ${VERSION} 不一致`);
|
||||||
|
}
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const asset = path.join(path.dirname(entry.abs), file.name);
|
||||||
|
if (!fs.existsSync(asset)) throw new Error(`缺少发布件 ${file.name}`);
|
||||||
|
const digest = sha256(asset);
|
||||||
|
if (digest !== file.sha256) throw new Error(`${file.name} 校验和不匹配`);
|
||||||
|
if (fs.statSync(asset).size !== file.size) throw new Error(`${file.name} 体积不匹配`);
|
||||||
|
fs.copyFileSync(asset, path.join(outDir, file.name));
|
||||||
|
rows.push(`${digest} ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort();
|
||||||
|
fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${rows.join('\n')}\n`);
|
||||||
|
console.log(`已校验 ${rows.length} 个发布件,汇总到 ${outDir}`);
|
||||||
|
for (const row of rows) console.log(' ' + row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.verify) verifyDownloads(args.verify);
|
||||||
|
else if (args.platform) await packRelease(args);
|
||||||
|
else throw new Error('缺少 --platform 或 --verify 参数');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TARGETS, parseArgs, readTarEntries };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('发布打包失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 830 B |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
// 从 icons/dist/<theme>/icon-*.png 生成 macOS 的 .icns。
|
||||||
|
// 用途:Windows 上没有 iconutil,但 icns 自 10.7 起支持直接内嵌 PNG,
|
||||||
|
// 因此容器可以手工拼出来,不必依赖 macOS 工具链。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DIST = path.join(__dirname, '..', 'dist');
|
||||||
|
|
||||||
|
// OSType -> 该槽位要求的像素尺寸。ic11/ic12/ic13/ic14 是 @2x 变体,
|
||||||
|
// 像素数等于逻辑尺寸的两倍,复用同一批 PNG 即可。
|
||||||
|
const SLOTS = [
|
||||||
|
['icp4', 16],
|
||||||
|
['icp5', 32],
|
||||||
|
['ic11', 32],
|
||||||
|
['ic12', 64],
|
||||||
|
['ic07', 128],
|
||||||
|
['ic13', 256],
|
||||||
|
['ic08', 256],
|
||||||
|
['ic14', 512],
|
||||||
|
['ic09', 512],
|
||||||
|
['ic10', 1024]
|
||||||
|
];
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
function pngSize(buf) {
|
||||||
|
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('不是 PNG 文件');
|
||||||
|
if (buf.readUInt32BE(8) !== 13 || buf.toString('latin1', 12, 16) !== 'IHDR') {
|
||||||
|
throw new Error('PNG 缺少 IHDR');
|
||||||
|
}
|
||||||
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(theme) {
|
||||||
|
const chunks = [];
|
||||||
|
for (const [osType, size] of SLOTS) {
|
||||||
|
const file = path.join(DIST, theme, `icon-${size}.png`);
|
||||||
|
const png = fs.readFileSync(file);
|
||||||
|
const dim = pngSize(png);
|
||||||
|
if (dim.width !== size || dim.height !== size) {
|
||||||
|
throw new Error(`${file} 期望 ${size}x${size},实际 ${dim.width}x${dim.height}`);
|
||||||
|
}
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header.write(osType, 0, 4, 'latin1');
|
||||||
|
header.writeUInt32BE(png.length + 8, 4);
|
||||||
|
chunks.push(header, png);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header.write('icns', 0, 4, 'latin1');
|
||||||
|
header.writeUInt32BE(body.length + 8, 4);
|
||||||
|
|
||||||
|
const out = path.join(DIST, `book-ai-${theme}.icns`);
|
||||||
|
fs.writeFileSync(out, Buffer.concat([header, body]));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const out = build(theme);
|
||||||
|
console.log(`${path.basename(out)} ${(fs.statSync(out).size / 1024).toFixed(1)} KB`);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ const { pathToFileURL } = require('url');
|
|||||||
const { Readable, Transform } = require('stream');
|
const { Readable, Transform } = require('stream');
|
||||||
const { pipeline } = require('stream/promises');
|
const { pipeline } = require('stream/promises');
|
||||||
|
|
||||||
const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
|
||||||
const RELEASES_API = 'https://api.github.com/repos/lofyer/peoplelib/releases/latest';
|
const RELEASES_API = 'https://api.github.com/repos/lofyer/peoplelib/releases/latest';
|
||||||
const RELEASES_PAGE = 'https://github.com/lofyer/peoplelib/releases';
|
const RELEASES_PAGE = 'https://github.com/lofyer/peoplelib/releases';
|
||||||
|
|
||||||
@@ -80,12 +79,21 @@ app.setAppUserModelId('com.peoplelib.client');
|
|||||||
|
|
||||||
const APP_ICON_DIR = path.join(__dirname, 'icons', 'dist');
|
const APP_ICON_DIR = path.join(__dirname, 'icons', 'dist');
|
||||||
function iconForTheme(theme) {
|
function iconForTheme(theme) {
|
||||||
return path.join(APP_ICON_DIR, theme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico');
|
const name = theme === 'light' ? 'light' : 'dark';
|
||||||
|
// .ico 只有 Windows 认;macOS/Linux 用 PNG,窗口图标不需要 icns
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? path.join(APP_ICON_DIR, `book-ai-${name}.ico`)
|
||||||
|
: path.join(APP_ICON_DIR, name, 'icon-256.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userDataDir = app.isPackaged
|
// macOS 的 .app 内部不可写(DMG 只读,且升级覆盖会连用户数据一起删),
|
||||||
? path.join(path.dirname(app.getPath('exe')), 'data')
|
// 只有 Windows 便携版才把 data/ 放在可执行文件旁边。
|
||||||
: path.join(app.getPath('appData'), 'PeopleLib');
|
function resolveUserDataDir() {
|
||||||
|
if (!app.isPackaged) return path.join(app.getPath('appData'), 'PeopleLib');
|
||||||
|
if (process.platform === 'win32') return path.join(path.dirname(app.getPath('exe')), 'data');
|
||||||
|
return path.join(app.getPath('appData'), 'PeopleLib');
|
||||||
|
}
|
||||||
|
const userDataDir = resolveUserDataDir();
|
||||||
app.setPath('userData', userDataDir);
|
app.setPath('userData', userDataDir);
|
||||||
|
|
||||||
const sources = require('./src/sources');
|
const sources = require('./src/sources');
|
||||||
@@ -99,11 +107,14 @@ const readerStore = require('./src/reader/store');
|
|||||||
const annotations = require('./src/reader/annotations');
|
const annotations = require('./src/reader/annotations');
|
||||||
const noteAssets = require('./src/reader/note-assets');
|
const noteAssets = require('./src/reader/note-assets');
|
||||||
const readerWindow = require('./src/reader/window');
|
const readerWindow = require('./src/reader/window');
|
||||||
|
const noteWindow = require('./src/reader/note-window');
|
||||||
const rangeSessions = require('./src/reader/range-sessions');
|
const rangeSessions = require('./src/reader/range-sessions');
|
||||||
const aiConfig = require('./src/reader/ai-config');
|
const aiConfig = require('./src/reader/ai-config');
|
||||||
const aiClient = require('./src/reader/ai-client');
|
const aiClient = require('./src/reader/ai-client');
|
||||||
|
const aiSessions = require('./src/reader/ai-sessions');
|
||||||
|
const aiImages = require('./src/reader/ai-images');
|
||||||
const { normalizeVisualContexts } = require('./src/reader/visual-context');
|
const { normalizeVisualContexts } = require('./src/reader/visual-context');
|
||||||
const { setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
|
const { UA: DL_UA, setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
|
||||||
zlibAuth.init(userDataDir, safeStorage);
|
zlibAuth.init(userDataDir, safeStorage);
|
||||||
semanticKey.init(userDataDir, safeStorage);
|
semanticKey.init(userDataDir, safeStorage);
|
||||||
settings.init(userDataDir);
|
settings.init(userDataDir);
|
||||||
@@ -115,6 +126,8 @@ readerStore.init(userDataDir);
|
|||||||
annotations.init(userDataDir);
|
annotations.init(userDataDir);
|
||||||
noteAssets.init(userDataDir);
|
noteAssets.init(userDataDir);
|
||||||
aiConfig.init(userDataDir, safeStorage);
|
aiConfig.init(userDataDir, safeStorage);
|
||||||
|
aiSessions.init(userDataDir);
|
||||||
|
aiImages.init(userDataDir);
|
||||||
// 启动时从持久化设置恢复代理
|
// 启动时从持久化设置恢复代理
|
||||||
try {
|
try {
|
||||||
setProxy(settings.get('proxy', ''));
|
setProxy(settings.get('proxy', ''));
|
||||||
@@ -136,6 +149,8 @@ const legacyImportPending = !settings.get('legacyImported', false);
|
|||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let activeDownloads = 0;
|
let activeDownloads = 0;
|
||||||
|
const downloadSessions = new Map();
|
||||||
|
const downloadSessionSenders = new Set();
|
||||||
let readerPurgeSeq = 0;
|
let readerPurgeSeq = 0;
|
||||||
let startupMaintenanceStarted = false;
|
let startupMaintenanceStarted = false;
|
||||||
const readerPurgeWaiters = new Map();
|
const readerPurgeWaiters = new Map();
|
||||||
@@ -163,6 +178,40 @@ function ensureReaderWritable(entryId) {
|
|||||||
if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除');
|
if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadSessionKey(senderId, requestId) {
|
||||||
|
return `${senderId}:${requestId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDownloadPartial(session) {
|
||||||
|
if (!session || !session.partial) return;
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(session.partial);
|
||||||
|
session.partial = '';
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') session.partial = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardDownloadSession(session) {
|
||||||
|
if (!session) return;
|
||||||
|
session.control = 'delete';
|
||||||
|
if (session.controller) session.controller.abort();
|
||||||
|
removeDownloadPartial(session);
|
||||||
|
if (session.state !== 'running') downloadSessions.delete(session.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackDownloadSender(webContents) {
|
||||||
|
const senderId = webContents.id;
|
||||||
|
if (downloadSessionSenders.has(senderId)) return;
|
||||||
|
downloadSessionSenders.add(senderId);
|
||||||
|
webContents.once('destroyed', () => {
|
||||||
|
downloadSessionSenders.delete(senderId);
|
||||||
|
for (const session of downloadSessions.values()) {
|
||||||
|
if (session.senderId === senderId) discardDownloadSession(session);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1240,
|
width: 1240,
|
||||||
@@ -208,16 +257,26 @@ function notifyLibraryChanged() {
|
|||||||
|
|
||||||
function notifyNotesChanged(data) {
|
function notifyNotesChanged(data) {
|
||||||
const payload = data && typeof data === 'object' ? data : {};
|
const payload = data && typeof data === 'object' ? data : {};
|
||||||
const windows = [mainWindow, ...readerWindow.all()];
|
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
|
||||||
for (const win of windows) {
|
for (const win of windows) {
|
||||||
if (win && !win.isDestroyed()) win.webContents.send('reader:notesChanged', payload);
|
if (win && !win.isDestroyed()) win.webContents.send('reader:notesChanged', payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyNoteWindowsChanged(noteIds) {
|
||||||
|
const payload = Array.isArray(noteIds) ? noteIds : noteWindow.openIds();
|
||||||
|
const windows = [mainWindow, ...readerWindow.all()];
|
||||||
|
for (const win of windows) {
|
||||||
|
if (win && !win.isDestroyed()) win.webContents.send('notes:windowsChanged', payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
noteWindow.setChangeListener(notifyNoteWindowsChanged);
|
||||||
|
|
||||||
function applyWindowIcons(theme) {
|
function applyWindowIcons(theme) {
|
||||||
currentUiTheme = theme === 'light' ? 'light' : 'dark';
|
currentUiTheme = theme === 'light' ? 'light' : 'dark';
|
||||||
const icon = iconForTheme(currentUiTheme);
|
const icon = iconForTheme(currentUiTheme);
|
||||||
const windows = [mainWindow, ...readerWindow.all()];
|
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
|
||||||
for (const win of windows) {
|
for (const win of windows) {
|
||||||
if (!win || win.isDestroyed()) continue;
|
if (!win || win.isDestroyed()) continue;
|
||||||
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
|
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
|
||||||
@@ -225,7 +284,7 @@ function applyWindowIcons(theme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notifyUiThemeChanged() {
|
function notifyUiThemeChanged() {
|
||||||
const windows = [mainWindow, ...readerWindow.all()];
|
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
|
||||||
for (const win of windows) {
|
for (const win of windows) {
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
win.webContents.send('ui:themeChanged', currentUiTheme);
|
win.webContents.send('ui:themeChanged', currentUiTheme);
|
||||||
@@ -252,6 +311,7 @@ app.on('window-all-closed', () => {
|
|||||||
if (process.platform !== 'darwin') app.quit();
|
if (process.platform !== 'darwin') app.quit();
|
||||||
});
|
});
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
|
for (const download of downloadSessions.values()) discardDownloadSession(download);
|
||||||
coverGenerator.close();
|
coverGenerator.close();
|
||||||
rangeSessions.closeAll().catch(() => {});
|
rangeSessions.closeAll().catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -415,7 +475,7 @@ ipcMain.handle('library:pickDir', async () => {
|
|||||||
ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => {
|
ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => {
|
||||||
const dest = String(dir || '').trim();
|
const dest = String(dir || '').trim();
|
||||||
if (!dest) throw new Error('目录不能为空');
|
if (!dest) throw new Error('目录不能为空');
|
||||||
if (activeDownloads) throw new Error('请等待当前下载完成后再切换书库目录');
|
if (activeDownloads || downloadSessions.size) throw new Error('请等待当前下载完成或删除未完成任务后再切换书库目录');
|
||||||
const previousSetting = settings.get('libraryDir', '');
|
const previousSetting = settings.get('libraryDir', '');
|
||||||
const previousRoot = library.getRoot();
|
const previousRoot = library.getRoot();
|
||||||
try {
|
try {
|
||||||
@@ -505,15 +565,112 @@ ipcMain.handle('library:remove', (_e, id, options) => wrap(() => {
|
|||||||
})();
|
})();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 批量整理走单次索引写入。逐条 update 会把索引重写 N 遍,
|
||||||
|
// 上千条的书库里批量改动会明显卡顿。
|
||||||
|
ipcMain.handle('library:updateMany', (_e, patches) => wrap(() => {
|
||||||
|
const list = Array.isArray(patches) ? patches : [];
|
||||||
|
if (list.length > 5000) throw new Error('单次批量更新条目过多');
|
||||||
|
const result = library.updateMany(list);
|
||||||
|
for (const entry of list) {
|
||||||
|
if (entry && entry.id != null) coverGenerator.ensure(entry.id).catch(() => {});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('library:removeMany', (_e, ids, options) => wrap(() => {
|
||||||
|
const list = (Array.isArray(ids) ? ids : []).map((id) => String(id));
|
||||||
|
if (list.length > 5000) throw new Error('单次批量移除条目过多');
|
||||||
|
const deleteFiles = !!(options && options.deleteFiles === true);
|
||||||
|
const deleteReadingData = !!(options && options.deleteReadingData === true);
|
||||||
|
return (async () => {
|
||||||
|
for (const id of list) await requestReaderPurge(id);
|
||||||
|
const purged = [];
|
||||||
|
if (deleteReadingData) {
|
||||||
|
for (const id of list) {
|
||||||
|
purgedReaderEntries.add(id);
|
||||||
|
purged.push(id);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
noteWindow.closeForEntries(list);
|
||||||
|
readerStore.forgetMany(list);
|
||||||
|
annotations.forgetMany(list);
|
||||||
|
aiSessions.forgetMany(list);
|
||||||
|
cleanupNoteAssets();
|
||||||
|
collectAiImages();
|
||||||
|
} catch (e) {
|
||||||
|
for (const id of purged) purgedReaderEntries.delete(id);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
for (const id of list) notifyNotesChanged({ entryId: id, type: 'forget' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return library.removeMany(list, deleteFiles);
|
||||||
|
} catch (e) {
|
||||||
|
for (const id of purged) purgedReaderEntries.delete(id);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 孤立阅读资料对账。笔记在「我的笔记」里仍可查看,属于有意保留,
|
||||||
|
// 因此只报告不自动删除;批注没有浏览入口,孤立后只会白占空间。
|
||||||
|
ipcMain.handle('reader:orphanReport', () => wrap(() => {
|
||||||
|
const knownIds = library.list().map((item) => String(item.id));
|
||||||
|
const notes = readerStore.orphanReport(knownIds);
|
||||||
|
const annotationOrphans = annotations.orphanReport(knownIds);
|
||||||
|
const chatOrphans = aiSessions.orphanReport(knownIds);
|
||||||
|
return {
|
||||||
|
notes,
|
||||||
|
annotations: annotationOrphans,
|
||||||
|
chats: chatOrphans,
|
||||||
|
totalBytes: annotationOrphans.reduce((sum, item) => sum + item.bytes, 0)
|
||||||
|
+ chatOrphans.reduce((sum, item) => sum + item.bytes, 0)
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('reader:purgeOrphans', (_e, options) => wrap(() => {
|
||||||
|
const scope = options && typeof options === 'object' ? options : {};
|
||||||
|
const knownIds = library.list().map((item) => String(item.id));
|
||||||
|
// 目标必须重新对账后确定,不接受渲染层直接传 ID,
|
||||||
|
// 否则一个过期的界面状态就能删掉仍在书库里的条目的阅读资料
|
||||||
|
const noteTargets = scope.notes === true
|
||||||
|
? readerStore.orphanReport(knownIds).map((item) => item.entryId)
|
||||||
|
: [];
|
||||||
|
const annotationTargets = scope.annotations === true
|
||||||
|
? annotations.orphanReport(knownIds).map((item) => item.entryId)
|
||||||
|
: [];
|
||||||
|
const chatTargets = scope.chats === true
|
||||||
|
? aiSessions.orphanReport(knownIds).map((item) => item.entryId)
|
||||||
|
: [];
|
||||||
|
if (noteTargets.length) noteWindow.closeForEntries(noteTargets);
|
||||||
|
const notesRemoved = noteTargets.length ? readerStore.forgetMany(noteTargets) : 0;
|
||||||
|
const annotationsRemoved = annotationTargets.length
|
||||||
|
? annotations.forgetMany(annotationTargets)
|
||||||
|
: 0;
|
||||||
|
const chatsRemoved = chatTargets.length ? aiSessions.forgetMany(chatTargets) : 0;
|
||||||
|
if (notesRemoved) {
|
||||||
|
cleanupNoteAssets();
|
||||||
|
for (const id of noteTargets) notifyNotesChanged({ entryId: id, type: 'forget' });
|
||||||
|
}
|
||||||
|
if (chatsRemoved) collectAiImages();
|
||||||
|
return { notesRemoved, annotationsRemoved, chatsRemoved };
|
||||||
|
}));
|
||||||
|
|
||||||
// 下载文件:默认直接存入书库目录并挂到条目上;
|
// 下载文件:默认直接存入书库目录并挂到条目上;
|
||||||
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
|
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
|
||||||
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
|
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
|
||||||
ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => {
|
ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => {
|
||||||
|
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
|
||||||
|
if (!/^[A-Za-z0-9_-]{1,100}$/.test(progressId)) return { ok: false, error: '下载任务 ID 无效' };
|
||||||
|
const key = downloadSessionKey(event.sender.id, progressId);
|
||||||
|
let download = downloadSessions.get(key);
|
||||||
|
if (download && download.state === 'running') return { ok: false, error: '该下载任务正在运行' };
|
||||||
|
if (download && download.control === 'delete') return { ok: false, error: '该下载任务已删除' };
|
||||||
|
trackDownloadSender(event.sender);
|
||||||
|
|
||||||
activeDownloads++;
|
activeDownloads++;
|
||||||
let partial = '';
|
|
||||||
let res = null;
|
let res = null;
|
||||||
let bodyHandled = false;
|
let bodyHandled = false;
|
||||||
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
|
|
||||||
const sendProgress = (data) => {
|
const sendProgress = (data) => {
|
||||||
if (!progressId || event.sender.isDestroyed()) return;
|
if (!progressId || event.sender.isDestroyed()) return;
|
||||||
event.sender.send('download:progress', { requestId: progressId, ...data });
|
event.sender.send('download:progress', { requestId: progressId, ...data });
|
||||||
@@ -523,22 +680,65 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
|
|||||||
if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接');
|
if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接');
|
||||||
const askSavePath = settings.get('askSavePath', false);
|
const askSavePath = settings.get('askSavePath', false);
|
||||||
const suggested = library.sanitize(suggestName || 'download.bin');
|
const suggested = library.sanitize(suggestName || 'download.bin');
|
||||||
let target;
|
if (!download) {
|
||||||
if (askSavePath) {
|
download = {
|
||||||
|
key,
|
||||||
|
senderId: event.sender.id,
|
||||||
|
requestId: progressId,
|
||||||
|
state: 'preparing',
|
||||||
|
control: '',
|
||||||
|
controller: null,
|
||||||
|
partial: '',
|
||||||
|
target: '',
|
||||||
|
defaultName: '',
|
||||||
|
askSavePath,
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
validator: '',
|
||||||
|
url: parsedUrl.toString(),
|
||||||
|
suggestName: String(suggestName || ''),
|
||||||
|
entryId,
|
||||||
|
extraHeaders: { ...(extraHeaders || {}) },
|
||||||
|
meta
|
||||||
|
};
|
||||||
|
downloadSessions.set(key, download);
|
||||||
|
}
|
||||||
|
if (download.url !== parsedUrl.toString()) throw new Error('续传链接与原任务不一致');
|
||||||
|
|
||||||
|
if (download.askSavePath && !download.target) {
|
||||||
const save = await dialog.showSaveDialog(liveWindow(), {
|
const save = await dialog.showSaveDialog(liveWindow(), {
|
||||||
title: '保存文件',
|
title: '保存文件',
|
||||||
defaultPath: path.join(library.filesDir(), suggested)
|
defaultPath: path.join(library.filesDir(), suggested)
|
||||||
});
|
});
|
||||||
if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } };
|
if (save.canceled || !save.filePath) {
|
||||||
target = save.filePath;
|
downloadSessions.delete(key);
|
||||||
|
return { ok: true, data: { canceled: true } };
|
||||||
|
}
|
||||||
|
download.target = save.filePath;
|
||||||
|
}
|
||||||
|
if (download.control === 'delete') {
|
||||||
|
downloadSessions.delete(key);
|
||||||
|
return { ok: true, data: { deleted: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) };
|
const headers = { 'User-Agent': DL_UA, ...download.extraHeaders };
|
||||||
headers['Referer'] = parsedUrl.origin + '/';
|
headers['Referer'] = parsedUrl.origin + '/';
|
||||||
|
const resumeBytes = download.partial && fs.existsSync(download.partial)
|
||||||
|
? fs.statSync(download.partial).size
|
||||||
|
: 0;
|
||||||
|
download.receivedBytes = resumeBytes;
|
||||||
|
if (resumeBytes > 0) {
|
||||||
|
headers['Range'] = `bytes=${resumeBytes}-`;
|
||||||
|
if (download.validator) headers['If-Range'] = download.validator;
|
||||||
|
}
|
||||||
|
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
download.controller = ac;
|
||||||
|
download.control = '';
|
||||||
|
download.state = 'running';
|
||||||
const timer = setTimeout(() => ac.abort(), 30000);
|
const timer = setTimeout(() => ac.abort(), 30000);
|
||||||
try {
|
try {
|
||||||
res = await fetchWithProxy(parsedUrl.toString(), {
|
res = await fetchWithProxy(download.url, {
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
headers,
|
headers,
|
||||||
signal: ac.signal
|
signal: ac.signal
|
||||||
@@ -546,30 +746,58 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
|
|||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
if (download.control) throw new Error('下载已中止');
|
||||||
if (!res.ok) throw new Error(`下载失败: ${res.status}`);
|
if (!res.ok) throw new Error(`下载失败: ${res.status}`);
|
||||||
const declaredSize = Number(res.headers.get('content-length'));
|
|
||||||
const totalBytes = Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : null;
|
|
||||||
let receivedBytes = 0;
|
|
||||||
let lastProgressAt = 0;
|
|
||||||
sendProgress({ receivedBytes, totalBytes, percent: totalBytes ? 0 : null });
|
|
||||||
|
|
||||||
const respName = filenameFromResponse(res, suggestName);
|
const resumed = resumeBytes > 0 && res.status === 206;
|
||||||
const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName);
|
if (resumeBytes > 0 && !resumed) {
|
||||||
const defaultName = library.sanitize(hasExt ? suggestName : respName);
|
removeDownloadPartial(download);
|
||||||
|
download.receivedBytes = 0;
|
||||||
|
}
|
||||||
|
const declaredSize = Number(res.headers.get('content-length'));
|
||||||
|
let totalBytes = Number.isFinite(declaredSize) && declaredSize > 0
|
||||||
|
? declaredSize + (resumed ? resumeBytes : 0)
|
||||||
|
: null;
|
||||||
|
const contentRange = res.headers.get('content-range') || '';
|
||||||
|
const rangeMatch = contentRange.match(/^bytes\s+(\d+)-\d+\/(\d+|\*)$/i);
|
||||||
|
if (resumed && (!rangeMatch || Number(rangeMatch[1]) !== resumeBytes)) {
|
||||||
|
throw new Error('远端服务器返回了错误的断点位置,请删除任务后重新下载');
|
||||||
|
}
|
||||||
|
if (rangeMatch && rangeMatch[2] !== '*') totalBytes = Number(rangeMatch[2]);
|
||||||
|
if (resumed && download.totalBytes && totalBytes && download.totalBytes !== totalBytes) {
|
||||||
|
throw new Error('远端文件在暂停期间发生变化,请删除任务后重新下载');
|
||||||
|
}
|
||||||
|
if (!resumed) download.validator = res.headers.get('etag') || res.headers.get('last-modified') || '';
|
||||||
|
download.totalBytes = totalBytes;
|
||||||
|
let receivedBytes = resumed ? resumeBytes : 0;
|
||||||
|
let lastProgressAt = 0;
|
||||||
|
sendProgress({
|
||||||
|
receivedBytes,
|
||||||
|
totalBytes,
|
||||||
|
percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
const respName = filenameFromResponse(res, download.suggestName);
|
||||||
|
const hasExt = download.suggestName && /\.[a-z0-9]{2,5}$/i.test(download.suggestName);
|
||||||
|
if (!download.defaultName) {
|
||||||
|
download.defaultName = library.sanitize(hasExt ? download.suggestName : respName);
|
||||||
|
}
|
||||||
const contentType = res.headers.get('content-type') || '';
|
const contentType = res.headers.get('content-type') || '';
|
||||||
const expectedExt = path.extname(target || defaultName).toLowerCase();
|
const expectedExt = path.extname(download.target || download.defaultName).toLowerCase();
|
||||||
if (/text\/html|application\/json/i.test(contentType)
|
if (/text\/html|application\/json/i.test(contentType)
|
||||||
&& !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) {
|
&& !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) {
|
||||||
throw new Error('下载地址返回了网页而不是文献文件');
|
throw new Error('下载地址返回了网页而不是文献文件');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target) target = library.allocFilePath(defaultName);
|
if (!download.target) download.target = library.allocFilePath(download.defaultName);
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(download.target), { recursive: true });
|
||||||
partial = path.join(
|
if (!download.partial) {
|
||||||
path.dirname(target),
|
download.partial = path.join(
|
||||||
`.${path.basename(target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
|
path.dirname(download.target),
|
||||||
);
|
`.${path.basename(download.target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!res.body) throw new Error('下载响应没有文件内容');
|
if (!res.body) throw new Error('下载响应没有文件内容');
|
||||||
let transferTimer;
|
let transferTimer;
|
||||||
const refreshTransferTimer = () => {
|
const refreshTransferTimer = () => {
|
||||||
@@ -580,6 +808,7 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
|
|||||||
transform(chunk, encoding, callback) {
|
transform(chunk, encoding, callback) {
|
||||||
refreshTransferTimer();
|
refreshTransferTimer();
|
||||||
receivedBytes += chunk.length;
|
receivedBytes += chunk.length;
|
||||||
|
download.receivedBytes = receivedBytes;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) {
|
if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) {
|
||||||
lastProgressAt = now;
|
lastProgressAt = now;
|
||||||
@@ -595,71 +824,127 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
|
|||||||
refreshTransferTimer();
|
refreshTransferTimer();
|
||||||
bodyHandled = true;
|
bodyHandled = true;
|
||||||
try {
|
try {
|
||||||
await pipeline(Readable.fromWeb(res.body), activity, fs.createWriteStream(partial, { flags: 'wx' }));
|
await pipeline(
|
||||||
|
Readable.fromWeb(res.body),
|
||||||
|
activity,
|
||||||
|
fs.createWriteStream(download.partial, { flags: resumed ? 'a' : 'wx' })
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(transferTimer);
|
clearTimeout(transferTimer);
|
||||||
}
|
}
|
||||||
if (askSavePath) {
|
if (download.control) throw new Error('下载已中止');
|
||||||
const backup = `${target}.${process.pid}-${Date.now()}.bak`;
|
|
||||||
|
if (download.askSavePath) {
|
||||||
|
const backup = `${download.target}.${process.pid}-${Date.now()}.bak`;
|
||||||
let backedUp = false;
|
let backedUp = false;
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(target)) {
|
if (fs.existsSync(download.target)) {
|
||||||
fs.renameSync(target, backup);
|
fs.renameSync(download.target, backup);
|
||||||
backedUp = true;
|
backedUp = true;
|
||||||
}
|
}
|
||||||
fs.renameSync(partial, target);
|
fs.renameSync(download.partial, download.target);
|
||||||
partial = '';
|
download.partial = '';
|
||||||
if (backedUp) {
|
if (backedUp) {
|
||||||
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ }
|
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
if (backedUp && !fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target);
|
if (backedUp && !fs.existsSync(download.target) && fs.existsSync(backup)) {
|
||||||
|
fs.renameSync(backup, download.target);
|
||||||
|
}
|
||||||
} catch (rollbackError) { /* ignore */ }
|
} catch (rollbackError) { /* ignore */ }
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
fs.linkSync(partial, target);
|
fs.linkSync(download.partial, download.target);
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code !== 'EEXIST') throw e;
|
if (e.code !== 'EEXIST') throw e;
|
||||||
target = library.allocFilePath(defaultName);
|
download.target = library.allocFilePath(download.defaultName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
|
try { fs.unlinkSync(download.partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
|
||||||
partial = '';
|
download.partial = '';
|
||||||
}
|
}
|
||||||
sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true });
|
sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true });
|
||||||
|
|
||||||
// 落库:优先挂到已有条目,否则用 meta 新建
|
// 落库:优先挂到已有条目,否则用 meta 新建
|
||||||
let id = entryId;
|
let id = download.entryId;
|
||||||
if (!id && meta) {
|
if (!id && download.meta) {
|
||||||
const existing = meta.sourceId && meta.sourcePostId
|
const existing = download.meta.sourceId && download.meta.sourcePostId
|
||||||
? library.findBySource(meta.sourceId, meta.sourcePostId) : null;
|
? library.findBySource(download.meta.sourceId, download.meta.sourcePostId) : null;
|
||||||
id = existing ? existing.id : library.add(meta).id;
|
id = existing ? existing.id : library.add(download.meta).id;
|
||||||
}
|
}
|
||||||
const entry = id ? library.attachFile(id, target) : null;
|
const entry = id ? library.attachFile(id, download.target) : null;
|
||||||
if (entry) coverGenerator.ensure(entry.id).catch(() => {});
|
if (entry) coverGenerator.ensure(entry.id).catch(() => {});
|
||||||
|
|
||||||
return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } };
|
downloadSessions.delete(key);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
path: download.target,
|
||||||
|
name: path.basename(download.target),
|
||||||
|
entryId: id || null,
|
||||||
|
entry,
|
||||||
|
receivedBytes,
|
||||||
|
totalBytes
|
||||||
|
}
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (res && res.body && !bodyHandled) {
|
if (res && res.body && !bodyHandled) {
|
||||||
try { await res.body.cancel(); } catch (cancelError) { /* ignore */ }
|
try { await res.body.cancel(); } catch (cancelError) { /* ignore */ }
|
||||||
}
|
}
|
||||||
if (partial) {
|
if (download && download.control === 'pause') {
|
||||||
try { fs.unlinkSync(partial); } catch (cleanupError) { /* ignore */ }
|
download.state = 'paused';
|
||||||
|
download.controller = null;
|
||||||
|
if (download.partial && fs.existsSync(download.partial)) {
|
||||||
|
download.receivedBytes = fs.statSync(download.partial).size;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
paused: true,
|
||||||
|
receivedBytes: download.receivedBytes,
|
||||||
|
totalBytes: download.totalBytes
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
if (download && download.control === 'delete') {
|
||||||
|
removeDownloadPartial(download);
|
||||||
|
downloadSessions.delete(key);
|
||||||
|
return { ok: true, data: { deleted: true } };
|
||||||
|
}
|
||||||
|
removeDownloadPartial(download);
|
||||||
|
downloadSessions.delete(key);
|
||||||
if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) {
|
if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) {
|
||||||
return { ok: false, error: '下载超时,请检查网络或代理设置' };
|
return { ok: false, error: '下载超时,请检查网络或代理设置' };
|
||||||
}
|
}
|
||||||
return { ok: false, error: e.message || String(e) };
|
return { ok: false, error: e.message || String(e) };
|
||||||
} finally {
|
} finally {
|
||||||
|
if (download) download.controller = null;
|
||||||
activeDownloads--;
|
activeDownloads--;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('download:pause', (event, requestId) => wrap(() => {
|
||||||
|
const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
|
||||||
|
const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
|
||||||
|
if (!download || download.state !== 'running' || !download.controller) return false;
|
||||||
|
download.control = 'pause';
|
||||||
|
download.controller.abort();
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('download:delete', (event, requestId) => wrap(() => {
|
||||||
|
const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
|
||||||
|
const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
|
||||||
|
if (!download) return false;
|
||||||
|
discardDownloadSession(download);
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
// 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次,
|
// 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次,
|
||||||
// 在资源管理器里定位该文件,而不是静默失败。
|
// 在资源管理器里定位该文件,而不是静默失败。
|
||||||
ipcMain.handle('shell:openPath', async (_e, p) => {
|
ipcMain.handle('shell:openPath', async (_e, p) => {
|
||||||
@@ -691,7 +976,7 @@ ipcMain.handle('dialog:pickLocal', (event, kind) => wrap(async () => {
|
|||||||
: ['openFile', 'multiSelections'],
|
: ['openFile', 'multiSelections'],
|
||||||
filters: sourceKind === 'folder'
|
filters: sourceKind === 'folder'
|
||||||
? undefined
|
? undefined
|
||||||
: [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr'] }]
|
: [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr'] }]
|
||||||
});
|
});
|
||||||
if (r.canceled || !r.filePaths.length) return null;
|
if (r.canceled || !r.filePaths.length) return null;
|
||||||
const records = await localImport.discover(r.filePaths);
|
const records = await localImport.discover(r.filePaths);
|
||||||
@@ -745,7 +1030,7 @@ ipcMain.handle('library:importLocal', (event, selectionId, options) => wrap(asyn
|
|||||||
|
|
||||||
// --- 阅读器 ---
|
// --- 阅读器 ---
|
||||||
|
|
||||||
const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3']);
|
const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3', '.txt', '.md']);
|
||||||
function isReaderSender(webContents) {
|
function isReaderSender(webContents) {
|
||||||
const expected = pathToFileURL(path.join(__dirname, 'src', 'ui', 'reader.html')).href;
|
const expected = pathToFileURL(path.join(__dirname, 'src', 'ui', 'reader.html')).href;
|
||||||
return !!readerWindow.fromWebContents(webContents)
|
return !!readerWindow.fromWebContents(webContents)
|
||||||
@@ -753,6 +1038,13 @@ function isReaderSender(webContents) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('reader:ready', (event) => wrap(() => readerWindow.markReady(event.sender)));
|
ipcMain.handle('reader:ready', (event) => wrap(() => readerWindow.markReady(event.sender)));
|
||||||
|
// 关闭书籍标签页或整个阅读窗口时,阅读进度与批注刚落盘,
|
||||||
|
// 书库卡片上的"最近阅读"排序和批注计数需要立刻跟上
|
||||||
|
ipcMain.handle('reader:entryClosed', (event) => wrap(() => {
|
||||||
|
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以上报关闭');
|
||||||
|
notifyLibraryChanged();
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
ipcMain.handle('reader:captureRect', (event, rect) => wrap(async () => {
|
ipcMain.handle('reader:captureRect', (event, rect) => wrap(async () => {
|
||||||
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以截取文档内容');
|
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以截取文档内容');
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
@@ -1024,13 +1316,54 @@ ipcMain.handle('reader:removeNote', (_e, entryId, noteId) => wrap(() => {
|
|||||||
ensureReaderWritable(id);
|
ensureReaderWritable(id);
|
||||||
const result = readerStore.removeNote(id, noteId);
|
const result = readerStore.removeNote(id, noteId);
|
||||||
if (result) {
|
if (result) {
|
||||||
|
// 窗口必须先退场再清理资产:留着的话它下次保存会把已删的笔记整条写回去
|
||||||
|
noteWindow.closeFor(noteId);
|
||||||
cleanupNoteAssets();
|
cleanupNoteAssets();
|
||||||
notifyNotesChanged({ entryId: id, noteId: String(noteId), type: 'remove' });
|
notifyNotesChanged({ entryId: id, noteId: String(noteId), type: 'remove' });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 笔记独立窗口。目标必须由主进程重新对账后确定,
|
||||||
|
// 渲染层给的 ID 只是查询条件,不能当授权凭据。
|
||||||
|
function findNote(entryId, noteId) {
|
||||||
|
const id = String(noteId == null ? '' : noteId);
|
||||||
|
if (!id) throw new Error('笔记 ID 无效');
|
||||||
|
const filters = entryId == null || entryId === '' ? {} : { entryId: String(entryId) };
|
||||||
|
const note = readerStore.listNotes(filters).find((item) => String(item.id) === id);
|
||||||
|
if (!note) throw new Error('笔记不存在或已被删除');
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('notes:openWindow', (_e, entryId, noteId) => wrap(() => {
|
||||||
|
const note = findNote(entryId, noteId);
|
||||||
|
noteWindow.open(note.entryId, note.id, __dirname, currentUiTheme);
|
||||||
|
return { entryId: note.entryId, noteId: note.id };
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('notes:getOne', (event, entryId, noteId) => wrap(() => {
|
||||||
|
// 笔记窗口只能读自己已打开的标签,避免这个通道变成遍历全部笔记的后门。
|
||||||
|
// 多标签之后授权从「等于某一条」变成「在标签集内」,放宽成「是笔记窗口就给」等于取消校验。
|
||||||
|
if (noteWindow.fromWebContents(event.sender)
|
||||||
|
&& !noteWindow.ownsNote(event.sender, noteId)) {
|
||||||
|
throw new Error('无权读取其它笔记');
|
||||||
|
}
|
||||||
|
return findNote(entryId, noteId);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 标签集由渲染层上报,但只用于广播与授权范围收窄,新增标签仍要过 findNote 对账
|
||||||
|
ipcMain.handle('notes:tabsChanged', (event, tabs) => wrap(() => {
|
||||||
|
noteWindow.setTabs(event.sender, tabs);
|
||||||
|
return noteWindow.openIds();
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('notes:shutdownReady', (event) => wrap(() => noteWindow.shutdownReady(event.sender)));
|
||||||
|
ipcMain.handle('notes:cancelClose', (event) => wrap(() => noteWindow.cancelClose(event.sender)));
|
||||||
|
|
||||||
|
ipcMain.handle('notes:openWindows', () => wrap(() => noteWindow.openIds()));
|
||||||
ipcMain.handle('reader:listNotes', (_e, filters) => wrap(() => readerStore.listNotes(filters || {})));
|
ipcMain.handle('reader:listNotes', (_e, filters) => wrap(() => readerStore.listNotes(filters || {})));
|
||||||
ipcMain.handle('reader:getNoteCounts', () => wrap(() => readerStore.getNoteCounts()));
|
ipcMain.handle('reader:getNoteCounts', () => wrap(() => readerStore.getNoteCounts()));
|
||||||
|
ipcMain.handle('reader:getAnnotationCounts', () => wrap(() => annotations.getCounts()));
|
||||||
ipcMain.handle('reader:listCollections', () => wrap(() => readerStore.listCollections()));
|
ipcMain.handle('reader:listCollections', () => wrap(() => readerStore.listCollections()));
|
||||||
ipcMain.handle('reader:addCollection', (_e, input) => wrap(() => {
|
ipcMain.handle('reader:addCollection', (_e, input) => wrap(() => {
|
||||||
const result = readerStore.addCollection(input);
|
const result = readerStore.addCollection(input);
|
||||||
@@ -1119,6 +1452,101 @@ ipcMain.handle('ai:clear', () => wrap(() => {
|
|||||||
return status;
|
return status;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 会话归属由主进程按 entryId 对账,渲染层给的 entryId 只作过滤条件,
|
||||||
|
// 不能当授权凭据:否则任意窗口都能读别的书的对话。
|
||||||
|
function readerOnly(event, fn) {
|
||||||
|
return wrap(() => {
|
||||||
|
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以管理 AI 会话');
|
||||||
|
return fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiSessionEntryId(value) {
|
||||||
|
const id = String(value == null ? '' : value);
|
||||||
|
if (!id || id === aiSessions.GLOBAL_ENTRY_ID) return aiSessions.GLOBAL_ENTRY_ID;
|
||||||
|
if (!library.get(id)) throw new Error('条目不存在');
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话文件可能残留已删除条目的记录,取其 entryId 时不再校验书库,
|
||||||
|
// 否则孤立会话既列不出来也删不掉。
|
||||||
|
function requireAiSession(sessionId) {
|
||||||
|
const meta = aiSessions.messages(sessionId, { limit: 1 }).meta;
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('ai:sessionList', (event, filters) => readerOnly(event, () => {
|
||||||
|
const raw = filters && typeof filters === 'object' ? filters : {};
|
||||||
|
const entryId = raw.entryId == null || raw.entryId === ''
|
||||||
|
? null
|
||||||
|
: aiSessionEntryId(raw.entryId);
|
||||||
|
return aiSessions.list(entryId ? { entryId } : {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('ai:sessionCreate', (event, input) => readerOnly(event, () => {
|
||||||
|
const raw = input && typeof input === 'object' ? input : {};
|
||||||
|
return aiSessions.create({
|
||||||
|
entryId: aiSessionEntryId(raw.entryId),
|
||||||
|
title: raw.title,
|
||||||
|
documentKey: raw.documentKey
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('ai:sessionRename', (event, sessionId, title) => readerOnly(event, () => {
|
||||||
|
requireAiSession(sessionId);
|
||||||
|
return aiSessions.rename(sessionId, title);
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('ai:sessionPin', (event, sessionId, pinned) => readerOnly(event, () => {
|
||||||
|
requireAiSession(sessionId);
|
||||||
|
return aiSessions.setPinned(sessionId, pinned === true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('ai:sessionRemove', (event, sessionId) => readerOnly(event, () => {
|
||||||
|
requireAiSession(sessionId);
|
||||||
|
const removed = aiSessions.remove(sessionId);
|
||||||
|
collectAiImages();
|
||||||
|
return removed;
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('ai:sessionClear', (event, sessionId) => readerOnly(event, () => {
|
||||||
|
requireAiSession(sessionId);
|
||||||
|
const meta = aiSessions.clear(sessionId);
|
||||||
|
collectAiImages();
|
||||||
|
return meta;
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('ai:sessionMessages', (event, sessionId, options) => readerOnly(event, () => {
|
||||||
|
const raw = options && typeof options === 'object' ? options : {};
|
||||||
|
return aiSessions.messages(sessionId, { limit: raw.limit, before: raw.before });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 图像 GC 的 keep 集合必须来自扫全部会话文件的 imageIds(),不能只读索引
|
||||||
|
function collectAiImages() {
|
||||||
|
try {
|
||||||
|
return aiImages.cleanup(aiSessions.imageIds());
|
||||||
|
} catch (error) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_HISTORY_BUDGET = { maxChars: 12000, maxMessages: 20 };
|
||||||
|
|
||||||
|
const AI_TASK_TITLES = {
|
||||||
|
summarize: '总结当前上下文',
|
||||||
|
translate: '翻译选中文本',
|
||||||
|
explain: '解释选中文本',
|
||||||
|
ask: '提问'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存进会话的是用户看到的那句话,不是整篇正文:
|
||||||
|
// 正文另有 contextRef 记录哈希与字数,把它当消息正文会让重开后的气泡变成几千字原文
|
||||||
|
function aiTurnTitle(task, question) {
|
||||||
|
const asked = String(question == null ? '' : question).trim();
|
||||||
|
if (asked) return asked;
|
||||||
|
return AI_TASK_TITLES[task] || '提问';
|
||||||
|
}
|
||||||
|
|
||||||
const aiRuns = new Map();
|
const aiRuns = new Map();
|
||||||
|
|
||||||
function aiRunKey(senderId, runId) {
|
function aiRunKey(senderId, runId) {
|
||||||
@@ -1160,40 +1588,132 @@ ipcMain.handle('ai:cancel', (event, runId) => wrap(() => {
|
|||||||
|
|
||||||
// 流式:增量通过 ai:delta 事件推给发起窗口,最终结果由 invoke 返回
|
// 流式:增量通过 ai:delta 事件推给发起窗口,最终结果由 invoke 返回
|
||||||
ipcMain.handle('ai:run', async (e, payload) => {
|
ipcMain.handle('ai:run', async (e, payload) => {
|
||||||
const { runId, task, text, question, visualContexts } = payload || {};
|
const { runId, sessionId, task, text, question, visualContexts, scope, locator, documentKey, fileIndex } = payload || {};
|
||||||
const id = String(runId || '');
|
const id = String(runId || '');
|
||||||
if (!isReaderSender(e.sender)) return { ok: false, error: '只有阅读器可以使用 AI 助手' };
|
if (!isReaderSender(e.sender)) return { ok: false, error: '只有阅读器可以使用 AI 助手' };
|
||||||
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: 'runId 无效' };
|
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: 'runId 无效' };
|
||||||
const key = aiRunKey(e.sender.id, id);
|
const key = aiRunKey(e.sender.id, id);
|
||||||
if (aiRuns.has(key)) return { ok: false, error: '该请求已在进行中' };
|
if (aiRuns.has(key)) return { ok: false, error: '该请求已在进行中' };
|
||||||
|
|
||||||
|
const chatId = sessionId == null || sessionId === '' ? '' : String(sessionId);
|
||||||
|
let meta = null;
|
||||||
|
if (chatId) {
|
||||||
|
try {
|
||||||
|
meta = requireAiSession(chatId);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: (err && err.message) || String(err) };
|
||||||
|
}
|
||||||
|
// 同一会话内不允许并发:两轮同时写同一个文件,后完成的那轮会覆盖前一轮的消息
|
||||||
|
for (const run of aiRuns.values()) {
|
||||||
|
if (run.sessionId && run.sessionId === chatId) {
|
||||||
|
return { ok: false, error: '该会话正在生成中,请先等待或停止' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ctl = new AbortController();
|
const ctl = new AbortController();
|
||||||
const wc = e.sender;
|
const wc = e.sender;
|
||||||
const abortOnDestroy = () => ctl.abort();
|
const abortOnDestroy = () => ctl.abort();
|
||||||
wc.once('destroyed', abortOnDestroy);
|
wc.once('destroyed', abortOnDestroy);
|
||||||
aiRuns.set(key, { controller: ctl, senderId: wc.id });
|
aiRuns.set(key, { controller: ctl, senderId: wc.id, sessionId: chatId });
|
||||||
|
|
||||||
|
const body = String(text == null ? '' : text);
|
||||||
|
let userMessageId = '';
|
||||||
|
let assistantMessageId = '';
|
||||||
|
let history = [];
|
||||||
|
let streamed = '';
|
||||||
try {
|
try {
|
||||||
const visuals = canonicalVisualContexts(visualContexts);
|
const visuals = canonicalVisualContexts(visualContexts);
|
||||||
|
if (chatId) {
|
||||||
|
// 历史必须在写入本轮之前取,否则当前提问会被当成自己的历史重复发一遍
|
||||||
|
history = aiSessions.historyFor(chatId, AI_HISTORY_BUDGET).messages;
|
||||||
|
const userMessage = aiSessions.appendUser(chatId, {
|
||||||
|
text: aiTurnTitle(task, question),
|
||||||
|
task,
|
||||||
|
contextRef: body || visuals.length ? {
|
||||||
|
scope,
|
||||||
|
chars: body.length,
|
||||||
|
hash: aiSessions.hashContext(body),
|
||||||
|
locator,
|
||||||
|
documentKey: documentKey || meta.documentKey,
|
||||||
|
fileIndex
|
||||||
|
} : null,
|
||||||
|
images: persistAiImages(visuals)
|
||||||
|
});
|
||||||
|
userMessageId = userMessage.id;
|
||||||
|
assistantMessageId = aiSessions.appendAssistant(chatId, { task }).id;
|
||||||
|
}
|
||||||
const full = await aiClient.stream({
|
const full = await aiClient.stream({
|
||||||
task,
|
task,
|
||||||
text,
|
text,
|
||||||
question,
|
question,
|
||||||
visualContexts: visuals,
|
visualContexts: visuals,
|
||||||
|
history,
|
||||||
signal: ctl.signal,
|
signal: ctl.signal,
|
||||||
onDelta: (piece) => {
|
onDelta: (piece) => {
|
||||||
if (!wc.isDestroyed()) wc.send('ai:delta', { runId: id, delta: piece });
|
streamed += piece;
|
||||||
|
if (!wc.isDestroyed()) {
|
||||||
|
wc.send('ai:delta', {
|
||||||
|
runId: id,
|
||||||
|
delta: piece,
|
||||||
|
sessionId: chatId,
|
||||||
|
messageId: assistantMessageId
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { ok: true, data: { text: full } };
|
if (chatId) settleAiAssistant(chatId, assistantMessageId, { text: full });
|
||||||
|
return { ok: true, data: { text: full, sessionId: chatId, userMessageId, assistantMessageId } };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err && err.name === 'AbortError') return { ok: false, error: '已取消', cancelled: true };
|
const cancelled = !!(err && err.name === 'AbortError');
|
||||||
return { ok: false, error: (err && err.message) || String(err) };
|
const message = cancelled ? '已取消' : ((err && err.message) || String(err));
|
||||||
|
// 失败与取消都要落盘:用户的提问已经花掉了 token,
|
||||||
|
// 已经流出来的残片也要留住,否则界面上看到的半截回答一重开就消失
|
||||||
|
if (chatId && assistantMessageId) {
|
||||||
|
settleAiAssistant(chatId, assistantMessageId, {
|
||||||
|
text: streamed,
|
||||||
|
cancelled,
|
||||||
|
error: cancelled ? null : message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cancelled) {
|
||||||
|
return { ok: false, error: message, cancelled: true, data: { sessionId: chatId, userMessageId, assistantMessageId } };
|
||||||
|
}
|
||||||
|
return { ok: false, error: message, data: { sessionId: chatId, userMessageId, assistantMessageId } };
|
||||||
} finally {
|
} finally {
|
||||||
wc.removeListener('destroyed', abortOnDestroy);
|
wc.removeListener('destroyed', abortOnDestroy);
|
||||||
aiRuns.delete(key);
|
aiRuns.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 落盘失败不能把已经拿到的回答变成请求失败,最多是这一轮没存住
|
||||||
|
function settleAiAssistant(chatId, messageId, patch) {
|
||||||
|
try {
|
||||||
|
return aiSessions.finishAssistant(chatId, messageId, patch);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAiImages(visuals) {
|
||||||
|
const stored = [];
|
||||||
|
for (const context of visuals) {
|
||||||
|
if (!context.includeImage || !context.image) continue;
|
||||||
|
try {
|
||||||
|
const put = aiImages.put(Buffer.from(context.image.base64, 'base64'), context.image.mimeType);
|
||||||
|
stored.push({
|
||||||
|
imageId: put.imageId,
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
width: context.image.width,
|
||||||
|
height: context.image.height,
|
||||||
|
bytes: put.bytes,
|
||||||
|
ocrIncluded: !!(context.ocr && context.ocr.include)
|
||||||
|
});
|
||||||
|
} catch (error) { /* 存图失败不影响本轮提问 */ }
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
// 通用设置读写(目前用于"下载前询问保存位置"开关)
|
// 通用设置读写(目前用于"下载前询问保存位置"开关)
|
||||||
ipcMain.handle('settings:get', (_e, key, def) => wrap(() => settings.get(key, def)));
|
ipcMain.handle('settings:get', (_e, key, def) => wrap(() => settings.get(key, def)));
|
||||||
ipcMain.handle('settings:set', (_e, key, value) => wrap(() => { settings.set(key, value); }));
|
ipcMain.handle('settings:set', (_e, key, value) => wrap(() => { settings.set(key, value); }));
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "peoplelib",
|
"name": "peoplelib",
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "peoplelib",
|
"name": "peoplelib",
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"foliate-js": "1.0.1",
|
"foliate-js": "1.0.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "peoplelib",
|
"name": "peoplelib",
|
||||||
"version": "1.3.0",
|
"version": "2.1.0",
|
||||||
"description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library)",
|
"description": "多源开放文献、电子书与本地书库客户端",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"author": "peoplelib",
|
"author": "peoplelib",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -12,7 +12,11 @@
|
|||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"test": "node --test \"src/_test/*.test.js\"",
|
"test": "node --test \"src/_test/*.test.js\"",
|
||||||
"build": "node build-portable.js",
|
"build": "node build-portable.js",
|
||||||
"portable": "node build-portable.js"
|
"portable": "node build-portable.js",
|
||||||
|
"build:mac": "node build-mac.js",
|
||||||
|
"build:linux": "node build-linux.js",
|
||||||
|
"release": "node build-release.js",
|
||||||
|
"icons:icns": "node icons/tools/make-icns.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"foliate-js": "1.0.1",
|
"foliate-js": "1.0.1",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
let downloadSeq = 0;
|
let downloadSeq = 0;
|
||||||
function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
|
function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
|
||||||
const requestId = `dl_${Date.now().toString(36)}_${(++downloadSeq).toString(36)}`;
|
|
||||||
const listener = (_event, data) => {
|
const listener = (_event, data) => {
|
||||||
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
|
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
|
||||||
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
|
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
|
||||||
@@ -13,6 +12,11 @@ function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress)
|
|||||||
.finally(() => ipcRenderer.removeListener('download:progress', listener));
|
.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) {
|
function captureReaderRect(rect) {
|
||||||
const value = rect && typeof rect === 'object' ? rect : {};
|
const value = rect && typeof rect === 'object' ? rect : {};
|
||||||
const area = {
|
const area = {
|
||||||
@@ -58,7 +62,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
findBySource: (sourceId, postId) => ipcRenderer.invoke('library:findBySource', sourceId, postId),
|
findBySource: (sourceId, postId) => ipcRenderer.invoke('library:findBySource', sourceId, postId),
|
||||||
add: (item) => ipcRenderer.invoke('library:add', item),
|
add: (item) => ipcRenderer.invoke('library:add', item),
|
||||||
update: (id, patch) => ipcRenderer.invoke('library:update', id, patch),
|
update: (id, patch) => ipcRenderer.invoke('library:update', id, patch),
|
||||||
|
updateMany: (patches) => ipcRenderer.invoke('library:updateMany', patches),
|
||||||
remove: (id, options) => ipcRenderer.invoke('library:remove', id, options),
|
remove: (id, options) => ipcRenderer.invoke('library:remove', id, options),
|
||||||
|
removeMany: (ids, options) => ipcRenderer.invoke('library:removeMany', ids, options),
|
||||||
getDir: () => ipcRenderer.invoke('library:getDir'),
|
getDir: () => ipcRenderer.invoke('library:getDir'),
|
||||||
pickDir: () => ipcRenderer.invoke('library:pickDir'),
|
pickDir: () => ipcRenderer.invoke('library:pickDir'),
|
||||||
setDir: (dir, migrate) => ipcRenderer.invoke('library:setDir', dir, migrate),
|
setDir: (dir, migrate) => ipcRenderer.invoke('library:setDir', dir, migrate),
|
||||||
@@ -87,6 +93,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
downloads: {
|
||||||
|
run: runDownload,
|
||||||
|
pause: (requestId) => ipcRenderer.invoke('download:pause', requestId),
|
||||||
|
delete: (requestId) => ipcRenderer.invoke('download:delete', requestId)
|
||||||
|
},
|
||||||
zlib: {
|
zlib: {
|
||||||
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
|
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
|
||||||
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
|
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
|
||||||
@@ -103,6 +114,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
},
|
},
|
||||||
reader: {
|
reader: {
|
||||||
ready: () => ipcRenderer.invoke('reader:ready'),
|
ready: () => ipcRenderer.invoke('reader:ready'),
|
||||||
|
entryClosed: () => ipcRenderer.invoke('reader:entryClosed'),
|
||||||
open: (entryId, fileIndex) => ipcRenderer.invoke('reader:open', entryId, fileIndex),
|
open: (entryId, fileIndex) => ipcRenderer.invoke('reader:open', entryId, fileIndex),
|
||||||
openAt: (entryId, fileIndex, documentKey, locator) => (
|
openAt: (entryId, fileIndex, documentKey, locator) => (
|
||||||
ipcRenderer.invoke('reader:openAt', entryId, fileIndex, documentKey, locator)
|
ipcRenderer.invoke('reader:openAt', entryId, fileIndex, documentKey, locator)
|
||||||
@@ -130,6 +142,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
removeNote: (entryId, noteId) => ipcRenderer.invoke('reader:removeNote', entryId, noteId),
|
removeNote: (entryId, noteId) => ipcRenderer.invoke('reader:removeNote', entryId, noteId),
|
||||||
listNotes: (filters) => ipcRenderer.invoke('reader:listNotes', filters),
|
listNotes: (filters) => ipcRenderer.invoke('reader:listNotes', filters),
|
||||||
getNoteCounts: () => ipcRenderer.invoke('reader:getNoteCounts'),
|
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'),
|
listCollections: () => ipcRenderer.invoke('reader:listCollections'),
|
||||||
addCollection: (input) => ipcRenderer.invoke('reader:addCollection', input),
|
addCollection: (input) => ipcRenderer.invoke('reader:addCollection', input),
|
||||||
updateCollection: (id, patch) => ipcRenderer.invoke('reader:updateCollection', id, patch),
|
updateCollection: (id, patch) => ipcRenderer.invoke('reader:updateCollection', id, patch),
|
||||||
@@ -173,12 +188,49 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
return () => ipcRenderer.removeListener('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: {
|
ai: {
|
||||||
status: () => ipcRenderer.invoke('ai:status'),
|
status: () => ipcRenderer.invoke('ai:status'),
|
||||||
save: (cfg) => ipcRenderer.invoke('ai:save', cfg),
|
save: (cfg) => ipcRenderer.invoke('ai:save', cfg),
|
||||||
clear: () => ipcRenderer.invoke('ai:clear'),
|
clear: () => ipcRenderer.invoke('ai:clear'),
|
||||||
run: (payload) => ipcRenderer.invoke('ai:run', payload),
|
run: (payload) => ipcRenderer.invoke('ai:run', payload),
|
||||||
cancel: (runId) => ipcRenderer.invoke('ai:cancel', runId),
|
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) => {
|
onChanged: (cb) => {
|
||||||
const h = (_e, data) => cb(data);
|
const h = (_e, data) => cb(data);
|
||||||
ipcRenderer.on('ai:changed', h);
|
ipcRenderer.on('ai:changed', h);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>页面不存在 - PeopleLib</title>
|
||||||
|
<meta name="description" content="请求的页面不存在。返回 PeopleLib 首页,或前往功能、隐私、下载与常见问题页面。">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<img class="brand-light" src="/assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="/assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">首页</a></li>
|
||||||
|
<li><a href="/features.html">功能</a></li>
|
||||||
|
<li><a href="/privacy.html">隐私</a></li>
|
||||||
|
<li><a href="/download.html">下载</a></li>
|
||||||
|
<li><a href="/faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<section class="notfound">
|
||||||
|
<div class="wrap">
|
||||||
|
<span class="eyebrow">404</span>
|
||||||
|
<h1>这个页面不存在</h1>
|
||||||
|
<p>地址可能拼错了,或者页面已经移动。</p>
|
||||||
|
<p class="btn-row btn-row-center">
|
||||||
|
<a class="btn btn-primary" href="/">回到首页</a>
|
||||||
|
<a class="btn btn-secondary" href="/download.html">下载页</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="legal">
|
||||||
|
PeopleLib,开放获取文献与图书的桌面客户端。MIT 许可。
|
||||||
|
<a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
reader.mesalogo.com
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# 站点部署说明
|
||||||
|
|
||||||
|
`reader.mesalogo.com` 的 GitHub Pages 部署步骤。站点是纯静态的,没有构建步骤、没有 npm 依赖、没有 CDN 外链。
|
||||||
|
|
||||||
|
本文里凡是标注「查证」的,出处是 GitHub 官方文档(已抓取正文核对);标注「推断」的是我根据文档与本仓库现状的判断,需要你实际操作时确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、文件清单
|
||||||
|
|
||||||
|
站点全部文件在 `site/`,目录本身就是站点根。
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `site/index.html` | 首页。价值主张、核心能力、隐私要点、真实截图、格式对照表 |
|
||||||
|
| `site/features.html` | 功能页。按真实功能清单展开,末尾有「不包含的功能」一节 |
|
||||||
|
| `site/privacy.html` | 隐私与安全。本地优先、自带密钥、确认后外发、进程边界、不绕 DRM、联网范围表 |
|
||||||
|
| `site/download.html` | 下载与安装。Windows 免安装步骤、macOS DMG 与首次打开的处理、首启建议 |
|
||||||
|
| `site/faq.html` | 常见问题。费用、数据位置、格式、macOS 拦截、离线、更新、超大 PDF 等 |
|
||||||
|
| `site/404.html` | 自定义 404。用根绝对路径引用资源(见下文注意事项) |
|
||||||
|
| `site/assets/style.css` | 唯一样式表。深浅两套配色随 `prefers-color-scheme` 切换 |
|
||||||
|
| `site/assets/shot-*.jpg` | 产品截图,由 `docs/screenshots/` 原图缩放转码而来 |
|
||||||
|
| `site/assets/icon-{light,dark}-{32,256,512}.png` | favicon 与品牌标记,从 `icons/dist/` 复制 |
|
||||||
|
| `site/assets/og-cover.jpg` | Open Graph 分享封面,1200x630 |
|
||||||
|
| `site/assets/build-assets.ps1` | 一次性资产生成脚本。换截图时手动重跑,站点本身不依赖它。只写 `site/assets/`,只读 `docs/screenshots/` 与 `icons/dist/` |
|
||||||
|
| `site/CNAME` | 自定义域名声明,内容是单行 `reader.mesalogo.com` |
|
||||||
|
| `site/.nojekyll` | 空文件,跳过 Jekyll 处理 |
|
||||||
|
| `site/robots.txt` | 允许全部抓取,指向 sitemap |
|
||||||
|
| `site/sitemap.xml` | 五个页面的站点地图 |
|
||||||
|
|
||||||
|
没有 JavaScript。折叠式常见问题用原生 `<details>` 实现,不需要脚本。
|
||||||
|
|
||||||
|
### 已做的校验
|
||||||
|
|
||||||
|
- 六个页面在移动宽度(390px)下 `scrollWidth == clientWidth`,无横向溢出。
|
||||||
|
- 全部内部链接与资源路径存在,全部页内锚点有效。
|
||||||
|
- 页面只引用本地资源。外部地址只出现在 `<a href>`(指向 github.com)与 `<link rel="canonical">`。
|
||||||
|
- 浏览器控制台无报错、无警告。
|
||||||
|
- 深浅两套配色的正文、次要文字、按钮、链接、页脚、表头对比度全部达到 WCAG AA,最低一项 4.75:1。
|
||||||
|
- 每页一个 `h1`,`lang="zh-CN"`,全部 `<img>` 有 `alt`,装饰性图标用空 `alt`。
|
||||||
|
- 全部文件 UTF-8 无 BOM,无 emoji,无破折号。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、部署方案
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
|
||||||
|
本仓库有两个远端,用途不同:
|
||||||
|
|
||||||
|
- `github`(`git@github.com:lofyer/peoplelib.git`,公开):源码与 CI,历史与本地无共同祖先。
|
||||||
|
- `origin`(私有 Gitea):完整源码,日常开发推这里。
|
||||||
|
|
||||||
|
GitHub Pages 只能托管在公开仓库或付费计划的私有仓库上。站点要走公开仓库,所以核心问题是:**怎么让站点有自己独立的一条历史,改站点不牵动源码分支。**
|
||||||
|
|
||||||
|
顺带提醒一句(查证):Pages 站点在互联网上始终是公开的,即使仓库是私有的。所以不要指望靠仓库权限藏住站点内容。
|
||||||
|
|
||||||
|
### 三种可选方式
|
||||||
|
|
||||||
|
GitHub 文档明确的发布源只有两类(查证):从某个分支发布,源目录只能是该分支的根 `/` 或 `/docs`;或者用自定义 GitHub Actions 工作流发布。
|
||||||
|
|
||||||
|
| 方式 | 是否可行 | 评价 |
|
||||||
|
|---|---|---|
|
||||||
|
| 公开仓库 `main` 分支的 `/docs` | 可行 | 但公开仓库的 `docs/screenshots/` 已被 README 引用,站点文件混进同一目录后,`docs/` 既是文档目录又是站点根,语义混乱。而且 README 与站点共用一次提交,改站点会污染 README 的历史 |
|
||||||
|
| 公开仓库独立 `gh-pages` 分支,根目录就是站点 | **推荐** | 分支里只有站点文件,改站点不牵动 `main` 的源码历史,两条线互不干扰 |
|
||||||
|
| GitHub Actions 工作流 | 不推荐 | 站点零构建,Actions 唯一的价值是自动化,收益抵不上多出来的运行时依赖与调试面。`main` 上的工作流只管应用的构建与发布 |
|
||||||
|
|
||||||
|
### 结论:公开仓库的 `gh-pages` orphan 分支,源目录 `/`
|
||||||
|
|
||||||
|
三个问题的答案:
|
||||||
|
|
||||||
|
1. **部署方式**:从公开仓库的 `gh-pages` 分支发布,源目录选根 `/`。不用 `/docs`,不用 Actions。
|
||||||
|
2. **CNAME 位置**:放在**发布分支的根目录**。因为 `site/` 的内容会成为 `gh-pages` 的根,所以 `site/CNAME` 推上去之后自然就在 `gh-pages` 根上,位置正确,不需要移动。(查证:从分支发布时,在 Settings 里保存自定义域名会自动在源分支根目录提交一个 `CNAME` 文件;反过来,你自己先放好这个文件也一样生效。用 Actions 发布则不会创建 `CNAME`,已有的也会被忽略。)
|
||||||
|
3. **`site/` 目录名会不会冲突**:不冲突,但**前提是用上面这个方案**。Pages 认不了名为 `site` 的源目录,它只认分支根或 `/docs`。而在本方案里 `site/` 只是私有仓库里的源目录,推送时把它的**内容**摊到 `gh-pages` 的根,Pages 看到的是根目录,所以目录叫什么都无所谓。**不需要改名,也不需要动仓库里其他任何地方。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、发布操作
|
||||||
|
|
||||||
|
### 3.1 首次发布
|
||||||
|
|
||||||
|
用一个临时 worktree 挂 orphan 分支,避免污染主工作区。以下命令在 PowerShell 下逐条执行,`$repo` 换成你的实际路径。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$repo = "D:\my_git\peoplelib"
|
||||||
|
$wt = "D:\my_git\peoplelib-pages" # 临时工作树,放在仓库外面
|
||||||
|
|
||||||
|
cd $repo
|
||||||
|
|
||||||
|
# 1. 建一个挂着 orphan 分支的工作树。orphan 意味着无父提交,天然与源码历史无共同祖先
|
||||||
|
git worktree add --orphan -b gh-pages $wt
|
||||||
|
|
||||||
|
# 2. 把站点内容摊到工作树根目录。注意是 site\ 的内容,不是 site 这个目录本身
|
||||||
|
Copy-Item -Path "$repo\site\*" -Destination $wt -Recurse -Force
|
||||||
|
Copy-Item -Path "$repo\site\.nojekyll" -Destination $wt -Force # 点开头的文件通配符可能漏掉,单独补一次
|
||||||
|
|
||||||
|
# 3. 确认工作树里只有站点文件,没有任何源码
|
||||||
|
cd $wt
|
||||||
|
git status --porcelain
|
||||||
|
Get-ChildItem -Force | Select-Object Name
|
||||||
|
|
||||||
|
# 4. 提交
|
||||||
|
git add -A
|
||||||
|
git commit -m "站点:发布 reader.mesalogo.com 首版"
|
||||||
|
|
||||||
|
# 5. 推到公开远端
|
||||||
|
git push github gh-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
`git worktree add --orphan` 需要 Git 2.42 或更高。本机是 2.55.0,可用。
|
||||||
|
|
||||||
|
发布脚本里不要 `git add` 那个 `assets/build-assets.ps1`,其实带上也无妨,它只是个纯文本工具脚本,不含源码逻辑,留着方便日后换图。你若不想让它进公开仓库,第 3 步之后删掉即可:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Remove-Item "$wt\assets\build-assets.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 后续更新
|
||||||
|
|
||||||
|
工作树保留着的话,更新只要重复复制加提交:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$repo = "D:\my_git\peoplelib"
|
||||||
|
$wt = "D:\my_git\peoplelib-pages"
|
||||||
|
|
||||||
|
cd $wt
|
||||||
|
# 先清干净,避免删掉的文件残留在分支上(保留 .git)
|
||||||
|
Get-ChildItem -Force | Where-Object { $_.Name -ne '.git' } | Remove-Item -Recurse -Force
|
||||||
|
|
||||||
|
Copy-Item -Path "$repo\site\*" -Destination $wt -Recurse -Force
|
||||||
|
Copy-Item -Path "$repo\site\.nojekyll" -Destination $wt -Force
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "站点:更新下载说明"
|
||||||
|
git push github gh-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
不再需要工作树时清理:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd $repo
|
||||||
|
git worktree remove ..\peoplelib-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 需要你决定的两件事
|
||||||
|
|
||||||
|
这两件都在 `site/` 之外,我没有动,交给你:
|
||||||
|
|
||||||
|
1. **`.gitignore`**:`site/` 目前**没有**被忽略,`git status` 能看到它。如果你希望站点源文件跟着私有仓库一起走版本管理(推荐,这样才有历史可查),就什么都不用改。如果你不想让它进私有仓库,自己在 `.gitignore` 里加 `/site/`,注意要带前导斜杠,否则会连带匹配其他层级的同名目录,这和仓库里 `/dist/` 那条规则是同一个坑。
|
||||||
|
|
||||||
|
2. **不要加 GitHub Actions workflow**。上面已经论证过不推荐。如果你后来改主意要走 Actions,记住一条(查证):用 Actions 发布时 `CNAME` 文件不会被创建,已存在的也会被忽略且不是必需的,域名完全由 Settings 里的配置决定,那时 `site/CNAME` 就成了无用文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、GitHub 仓库设置
|
||||||
|
|
||||||
|
在 `https://github.com/lofyer/peoplelib` 上操作。
|
||||||
|
|
||||||
|
1. 进 **Settings**,左侧 **Code and automation** 分组里点 **Pages**。
|
||||||
|
2. **Build and deployment** 的 Source 选 **Deploy from a branch**。
|
||||||
|
3. Branch 选 **`gh-pages`**,Folder 选 **`/ (root)`**,点 **Save**。
|
||||||
|
4. **Custom domain** 填 `reader.mesalogo.com`,点 **Save**。
|
||||||
|
|
||||||
|
第 4 步之后 GitHub 会自动跑一次 DNS 检查。因为 `site/CNAME` 已经在分支根上且内容正确,这一步通常不会再产生额外提交;如果 GitHub 仍然自己提交了一次 `CNAME`,那是正常行为(查证:从分支发布时保存自定义域名会在源分支根目录提交 `CNAME`),下次更新前先 `git pull github gh-pages` 同步一下即可。
|
||||||
|
|
||||||
|
### 关于域名验证
|
||||||
|
|
||||||
|
GitHub 文档建议(查证):**先验证自定义域名,再把它加到仓库里**,以提升安全性、避免域名被抢占。验证入口在个人或组织的 **Settings → Pages → Add a domain**,它会要求你加一条 `_github-pages-challenge-<user>.mesalogo.com` 的 TXT 记录。
|
||||||
|
|
||||||
|
这一步不是必须的,但如果你以后停用了 Pages 而 DNS 记录还留着,未验证的域名可能被别人拿去托管他们自己的站点。建议做。(查证:文档明确说明未验证且站点停用时存在被接管的风险。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、DNS 配置
|
||||||
|
|
||||||
|
`reader.mesalogo.com` 是**子域名**,不是 apex 域名。这个区别决定了记录类型。
|
||||||
|
|
||||||
|
### 需要加的记录
|
||||||
|
|
||||||
|
一条,就一条(查证):
|
||||||
|
|
||||||
|
| 类型 | 名称 | 值 | TTL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CNAME` | `reader`(即 `reader.mesalogo.com`) | `lofyer.github.io` | 自动 / 默认 |
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 值是 `lofyer.github.io`,**不带仓库名**。文档原文是 CNAME 记录应始终指向 `<user>.github.io` 或 `<organization>.github.io`,排除仓库名。(查证)
|
||||||
|
- 值不带 `https://`,不带结尾斜杠。有些 DNS 面板要求结尾带点写成 `lofyer.github.io.`,按面板的格式来。
|
||||||
|
- **不要**加 A 记录或 AAAA 记录。那四个 `185.199.10x.153` 与对应的 IPv6 地址是给 **apex 域名**(`example.com` 这种)用的。子域名只需要 CNAME。(查证)
|
||||||
|
- **不要**用通配符记录如 `*.mesalogo.com`。文档强烈反对,因为即使验证了域名,通配符覆盖下的更深层子域名仍可能被接管。(查证)
|
||||||
|
|
||||||
|
`site/CNAME` 文件与 DNS 里的 CNAME 记录是两件不同的东西,名字撞车而已:文件告诉 GitHub「这个站点用哪个域名」,DNS 记录告诉全世界「这个域名指向哪台服务器」。两边都要配。
|
||||||
|
|
||||||
|
### 当前状态
|
||||||
|
|
||||||
|
我查过(`Resolve-DnsName`):
|
||||||
|
|
||||||
|
- `reader.mesalogo.com` 目前**不存在**,返回「DNS 名称不存在」。所以是全新添加,不会覆盖已有记录。
|
||||||
|
- `mesalogo.com` 的权威 NS 是 `serena.ns.cloudflare.com` 与 `aarav.ns.cloudflare.com`,也就是**域名托管在 Cloudflare**。
|
||||||
|
|
||||||
|
### Cloudflare 特有注意事项(推断,非 GitHub 文档内容)
|
||||||
|
|
||||||
|
在 Cloudflare 面板加这条 CNAME 时,右侧有个 Proxy status 开关:
|
||||||
|
|
||||||
|
- 建议先设成 **DNS only**(灰色云朵)。橙色云朵代表 Cloudflare 代理,此时 Cloudflare 会自己终止 TLS,GitHub 那边的 DNS 检查可能拿不到期望的应答,导致证书签发卡住或反复失败。
|
||||||
|
- 等 GitHub 侧证书签发成功、`https://reader.mesalogo.com` 能正常打开之后,如果你确实想用 Cloudflare 的 CDN,再切成橙色云朵,并把 SSL/TLS 模式设为 **Full (strict)**。不要用 Flexible,那会造成 Cloudflare 到 GitHub 之间走明文。
|
||||||
|
- Cloudflare 默认 TTL 是 Auto,不用改。
|
||||||
|
|
||||||
|
这一节是我的操作建议,GitHub 文档不涉及具体 DNS 服务商。请以实际结果为准,卡住了先把云朵切灰。
|
||||||
|
|
||||||
|
### 验证 DNS 是否生效
|
||||||
|
|
||||||
|
Windows 没有 `dig`(查证:文档明确提到这一点并推荐 `Resolve-DnsName`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Resolve-DnsName reader.mesalogo.com -Type CNAME
|
||||||
|
```
|
||||||
|
|
||||||
|
期望看到 `NameHost` 是 `lofyer.github.io`。
|
||||||
|
|
||||||
|
DNS 变更最多需要 24 小时传播(查证)。多数情况几分钟就好,但如果刚改完查不到,先等,别急着反复改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、HTTPS 证书
|
||||||
|
|
||||||
|
顺序很重要:**先 DNS 生效,再等证书,最后勾 Enforce HTTPS。**
|
||||||
|
|
||||||
|
流程(查证):
|
||||||
|
|
||||||
|
1. 你在 Settings → Pages 里保存或修改自定义域名后,GitHub 自动开始一次 DNS 检查。
|
||||||
|
2. 检查通过后,GitHub 排队向 Let's Encrypt 申请 TLS 证书,拿到后自动部署到负责 Pages TLS 终止的服务器上。
|
||||||
|
3. 全流程成功后,Settings → Pages 的自定义域名旁边会出现一个**对勾**。
|
||||||
|
4. 这时再勾选 **Enforce HTTPS**,所有 HTTP 请求会被透明重定向到 HTTPS。
|
||||||
|
|
||||||
|
排障(查证):如果点了 Save 之后**几分钟**还没完成,出现「Certificate not yet created」,就点域名旁边的 **Remove**,重新输入域名再 **Save**,这会取消并重启签发流程。
|
||||||
|
|
||||||
|
关于 Enforce HTTPS 的勾选时机:文档说所有 Pages 站点包括正确配置了自定义域名的站点都支持 HTTPS 与 HTTPS 强制。**推断**:在证书还没签发出来(域名旁边没有对勾)时,这个复选框通常是灰的点不动,或者勾上会导致站点短时间打不开。所以按上面的顺序走,看到对勾再勾它。
|
||||||
|
|
||||||
|
混合内容(查证):如果页面里有 `http://` 开头的图片、CSS 或 JS,站点会被判定为混合内容。本站点不存在这个问题,所有资源都是相对路径引用的本地文件,已经校验过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、注意事项与已知坑
|
||||||
|
|
||||||
|
### 404 页面用的是根绝对路径
|
||||||
|
|
||||||
|
`site/404.html` 里的资源引用写成 `/assets/style.css` 这样的根绝对路径,因为 GitHub 会拿这个页面响应任意深度的错误路径,相对路径在 `/a/b/c` 这种地址下会解析错。
|
||||||
|
|
||||||
|
**代价**:在自定义域名生效之前,站点临时地址是 `https://lofyer.github.io/peoplelib/`,此时 404 页面的样式与图标会加载失败(它去找 `lofyer.github.io/assets/...` 而不是 `/peoplelib/assets/...`)。域名生效后站点在根路径上,一切正常。其他五个页面全部用相对路径,两种地址下都正常。
|
||||||
|
|
||||||
|
如果你想在临时地址下也让 404 页面完整,把该文件里的 `/assets/` 与 `/features.html` 等改成相对路径,代价是深层路径下样式丢失。二者不能同时满足,建议维持现状,等域名生效。
|
||||||
|
|
||||||
|
### `.nojekyll`
|
||||||
|
|
||||||
|
空文件,作用是跳过 Jekyll 处理。当前站点里没有下划线开头的文件或目录,严格说不加也能正常发布;加上是防御性的,避免以后新增 `_something` 之类的路径时被 Jekyll 悄悄吞掉。(Jekyll 忽略下划线前缀路径这一行为属于既有共识,本次未逐字查证 GitHub 文档;`.nojekyll` 本身在发布源文档里被提到是外部 CI 部署的常见做法。)
|
||||||
|
|
||||||
|
复制文件时留意:PowerShell 的 `Copy-Item site\*` 通配符可能不匹配点开头的文件,所以上面的命令里单独补了一次 `.nojekyll`。这个坑很容易漏,漏了不会报错,只是文件没过去。
|
||||||
|
|
||||||
|
### 版本号会过期
|
||||||
|
|
||||||
|
页脚、首页与下载页都写了 **2.0.0**,取自 `package.json` 的 `version` 字段。发新版时记得改,一共出现在这几处:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Select-String -Path D:\my_git\peoplelib\site\*.html -Pattern '1\.3\.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 站点不引用 `docs/screenshots/` 原图
|
||||||
|
|
||||||
|
`site/assets/shot-*.jpg` 是压缩转码后的副本,和 `docs/screenshots/` 的原始 PNG 相互独立。换截图时替换 `docs/screenshots/` 里的原图,然后重跑:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File D:\my_git\peoplelib\site\assets\build-assets.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本只写 `site/assets/`,只读 `docs/screenshots/` 与 `icons/dist/`。
|
||||||
|
|
||||||
|
### 仓库根目录的诊断图没有被使用
|
||||||
|
|
||||||
|
`font-missing-diagnostic.png`、`probe*.json` 之类的诊断产物一个都没进站点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、需要你补的资产
|
||||||
|
|
||||||
|
### 现在用的是真实截图,不是占位图
|
||||||
|
|
||||||
|
站点已经用上了 `docs/screenshots/` 里的六张真实截图,缩放到 1600px 宽、JPEG 质量 88:
|
||||||
|
|
||||||
|
| 站点文件 | 来源 | 用在哪 |
|
||||||
|
|---|---|---|
|
||||||
|
| `shot-library.jpg` | `PeopleLib_bAecy2Izab.png` | 首页主视觉、OG 封面 |
|
||||||
|
| `shot-search.jpg` | `PeopleLib_34BtvLsqDE.png` | 首页画廊、功能页检索节 |
|
||||||
|
| `shot-reader.jpg` | `PeopleLib_ScQMUA2D24.png` | 首页画廊、功能页阅读节 |
|
||||||
|
| `shot-ai.jpg` | `PeopleLib_2N6zVpCFBA.png` | 首页画廊、功能页 AI 节 |
|
||||||
|
| `shot-ai-confirm.jpg` | `ai-send-confirmation.png` | 首页画廊、隐私页确认框一节 |
|
||||||
|
| `shot-annotations.jpg` | `pdf-annotations.png` | 功能页批注节 |
|
||||||
|
|
||||||
|
所以站点**不缺图也能直接上线**。下面是可以让它更好的补充项,都是可选的。
|
||||||
|
|
||||||
|
### 建议补拍的截图
|
||||||
|
|
||||||
|
| 优先级 | 内容 | 用在哪 | 建议尺寸 | 说明 |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| 高 | **AI 设置页**,展示接口地址、协议选择与 Key 输入框 | 隐私页「AI 密钥」一节 | 宽 ≥ 1600px,16:10 左右 | 目前这一节全是文字。给出真实界面能显著提升「自带密钥」这个卖点的说服力。**截图前务必清空或涂掉真实 Key** |
|
||||||
|
| 高 | **画布笔记**,最好是以 PDF 页面作底版的那种 | 功能页「笔记与批注」节 | 宽 ≥ 1600px | 画布笔记是差异化功能,现在只有文字描述 |
|
||||||
|
| 中 | **书架与标签的批量整理**,多选状态下的操作栏 | 功能页「本地书库」节 | 宽 ≥ 1600px | 那一节现在是定义列表,没有配图 |
|
||||||
|
| 中 | **macOS 首次打开的系统提示框**,以及右键菜单里的「打开」 | 下载页 macOS 节、常见问题 | 宽 800 至 1200px 即可 | 这是最容易让用户误判为病毒的环节,配图比文字管用得多。需要在 macOS 上截 |
|
||||||
|
| 中 | **EPUB 阅读界面**,带目录侧栏 | 功能页 EPUB 卡片 | 宽 ≥ 1600px | 现有截图全是 PDF,看不出 EPUB 的重排效果 |
|
||||||
|
| 低 | **浅色主题下的书库界面** | 首页主视觉的浅色替换 | 宽 ≥ 1600px | 现有截图都是深色界面,浅色站点配色下略显突兀。若要做,需要给 `<picture>` 加 `prefers-color-scheme` 分支,改动量不大但需要我再写一次 |
|
||||||
|
| 低 | **代理设置界面** | 功能页「代理与镜像容错」节 | 宽 ≥ 1600px | 截图前遮掉真实代理地址 |
|
||||||
|
|
||||||
|
补图流程:把新 PNG 放进 `docs/screenshots/`,在 `site/assets/build-assets.ps1` 的 `$map` 数组里加一行映射,重跑脚本,然后在对应 HTML 里插 `<figure class="shot">`。
|
||||||
|
|
||||||
|
### 图标
|
||||||
|
|
||||||
|
已经复用 `icons/dist/` 的应用图标,浅色深色两套按 `prefers-color-scheme` 切换,不需要新画。
|
||||||
|
|
||||||
|
唯一缺的是 **`favicon.ico`**。当前只提供 32px PNG favicon,现代浏览器都认,但少数老浏览器和某些抓取器会去根目录硬找 `/favicon.ico`。要补的话把 `icons/dist/book-ai-light.ico` 复制成 `site/favicon.ico` 即可,属于可选项。
|
||||||
|
|
||||||
|
### Open Graph 图
|
||||||
|
|
||||||
|
`site/assets/og-cover.jpg` 已生成,1200x630,81 KB,内容是深色底加应用图标、产品名、两行说明与域名,右侧放书库截图。由 `build-assets.ps1` 用 GDI+ 绘制,字体是「Microsoft YaHei」。
|
||||||
|
|
||||||
|
**推断**:如果这张图要在正式场合露脸,建议你用设计工具重做一版。脚本生成的版式够用但排版粗糙,尤其是文字与截图之间的留白比例。替换时直接覆盖 `site/assets/og-cover.jpg`,保持 1200x630 与文件名不变,HTML 里的 `og:image` 尺寸声明就不用动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、上线后的自查清单
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# DNS
|
||||||
|
Resolve-DnsName reader.mesalogo.com -Type CNAME
|
||||||
|
|
||||||
|
# 首页可达且是 200
|
||||||
|
curl.exe -sSI https://reader.mesalogo.com/ | Select-Object -First 1
|
||||||
|
|
||||||
|
# HTTP 是否重定向到 HTTPS(勾了 Enforce HTTPS 之后应该是 301)
|
||||||
|
curl.exe -sSI http://reader.mesalogo.com/ | Select-Object -First 3
|
||||||
|
|
||||||
|
# 404 页面生效(应返回 404 且内容是中文自定义页)
|
||||||
|
curl.exe -sS -o NUL -w "%{http_code}`n" https://reader.mesalogo.com/nonexistent
|
||||||
|
|
||||||
|
# 五个页面全部可达
|
||||||
|
'', 'features.html', 'privacy.html', 'download.html', 'faq.html' | ForEach-Object {
|
||||||
|
$u = "https://reader.mesalogo.com/$_"
|
||||||
|
"{0} {1}" -f (curl.exe -sS -o NUL -w "%{http_code}" $u), $u
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器里再手工看四件事:
|
||||||
|
|
||||||
|
1. 切换系统深浅色主题,页面配色跟着变。
|
||||||
|
2. 手机上打开,导航与卡片不溢出。
|
||||||
|
3. 把首页链接贴进任意支持 Open Graph 预览的地方,标题、描述与封面图正常显示。
|
||||||
|
4. 键盘按 Tab,焦点框可见,第一次 Tab 出现「跳到主要内容」。
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 一次性资产生成脚本:把 docs/screenshots 与 icons/dist 的原图缩放并转码到 site/assets。
|
||||||
|
# 站点本身零构建,这个脚本只在替换截图时手动重跑。
|
||||||
|
param(
|
||||||
|
[string]$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
)
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$outDir = $PSScriptRoot
|
||||||
|
$srcShots = Join-Path $Repo 'docs\screenshots'
|
||||||
|
$srcIcons = Join-Path $Repo 'icons\dist'
|
||||||
|
|
||||||
|
function Save-Jpeg {
|
||||||
|
param([System.Drawing.Bitmap]$Bitmap, [string]$Path, [int]$Quality = 88)
|
||||||
|
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
|
||||||
|
$params = New-Object System.Drawing.Imaging.EncoderParameters 1
|
||||||
|
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), ([int]$Quality)
|
||||||
|
$Bitmap.Save($Path, $codec, $params)
|
||||||
|
$params.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resize-Shot {
|
||||||
|
param([string]$In, [string]$Out, [int]$MaxWidth = 1600, [int]$Quality = 88)
|
||||||
|
$src = [System.Drawing.Image]::FromFile($In)
|
||||||
|
try {
|
||||||
|
$scale = [Math]::Min(1.0, $MaxWidth / $src.Width)
|
||||||
|
$w = [int][Math]::Round($src.Width * $scale)
|
||||||
|
$h = [int][Math]::Round($src.Height * $scale)
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap $w, $h
|
||||||
|
try {
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.DrawImage($src, 0, 0, $w, $h)
|
||||||
|
$g.Dispose()
|
||||||
|
Save-Jpeg -Bitmap $bmp -Path $Out -Quality $Quality
|
||||||
|
"{0}`t{1}x{2}`t{3} KB" -f (Split-Path $Out -Leaf), $w, $h, [Math]::Round((Get-Item $Out).Length / 1KB)
|
||||||
|
} finally { $bmp.Dispose() }
|
||||||
|
} finally { $src.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = @(
|
||||||
|
@{ In = 'PeopleLib_bAecy2Izab.png'; Out = 'shot-library.jpg' }
|
||||||
|
@{ In = 'PeopleLib_34BtvLsqDE.png'; Out = 'shot-search.jpg' }
|
||||||
|
@{ In = 'PeopleLib_ScQMUA2D24.png'; Out = 'shot-reader.jpg' }
|
||||||
|
@{ In = 'PeopleLib_2N6zVpCFBA.png'; Out = 'shot-ai.jpg' }
|
||||||
|
@{ In = 'ai-send-confirmation.png'; Out = 'shot-ai-confirm.jpg' }
|
||||||
|
@{ In = 'pdf-annotations.png'; Out = 'shot-annotations.jpg' }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($m in $map) {
|
||||||
|
$in = Join-Path $srcShots $m.In
|
||||||
|
if (Test-Path $in) { Resize-Shot -In $in -Out (Join-Path $outDir $m.Out) }
|
||||||
|
else { Write-Warning "缺少源图 $in" }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($v in @('light', 'dark')) {
|
||||||
|
foreach ($size in @(32, 256, 512)) {
|
||||||
|
$in = Join-Path $srcIcons "$v\icon-$size.png"
|
||||||
|
if (Test-Path $in) { Copy-Item $in (Join-Path $outDir "icon-$v-$size.png") -Force }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Open Graph 封面:深色底 + 应用图标 + 书库截图,1200x630。
|
||||||
|
$og = New-Object System.Drawing.Bitmap 1200, 630
|
||||||
|
try {
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($og)
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
|
||||||
|
$g.Clear([System.Drawing.ColorTranslator]::FromHtml('#12141a'))
|
||||||
|
|
||||||
|
$shot = Join-Path $outDir 'shot-library.jpg'
|
||||||
|
if (Test-Path $shot) {
|
||||||
|
$img = [System.Drawing.Image]::FromFile($shot)
|
||||||
|
try {
|
||||||
|
$targetW = 660
|
||||||
|
$scale = $targetW / $img.Width
|
||||||
|
$h = [int][Math]::Round($img.Height * $scale)
|
||||||
|
$g.DrawImage($img, 500, [int]((630 - $h) / 2), $targetW, $h)
|
||||||
|
} finally { $img.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$iconPath = Join-Path $outDir 'icon-dark-256.png'
|
||||||
|
if (Test-Path $iconPath) {
|
||||||
|
$icon = [System.Drawing.Image]::FromFile($iconPath)
|
||||||
|
try { $g.DrawImage($icon, 72, 132, 112, 112) } finally { $icon.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$white = New-Object System.Drawing.SolidBrush ([System.Drawing.ColorTranslator]::FromHtml('#f4f5f7'))
|
||||||
|
$muted = New-Object System.Drawing.SolidBrush ([System.Drawing.ColorTranslator]::FromHtml('#a8aeba'))
|
||||||
|
$fTitle = New-Object System.Drawing.Font 'Microsoft YaHei', 44, ([System.Drawing.FontStyle]::Bold)
|
||||||
|
$fSub = New-Object System.Drawing.Font 'Microsoft YaHei', 19
|
||||||
|
$fFoot = New-Object System.Drawing.Font 'Microsoft YaHei', 15
|
||||||
|
$g.DrawString('PeopleLib', $fTitle, $white, 68, 268)
|
||||||
|
$g.DrawString("多源文献检索`n本地书库与内置阅读器", $fSub, $muted, 72, 348)
|
||||||
|
$g.DrawString('reader.mesalogo.com', $fFoot, $muted, 72, 470)
|
||||||
|
$g.Dispose()
|
||||||
|
Save-Jpeg -Bitmap $og -Path (Join-Path $outDir 'og-cover.jpg') -Quality 86
|
||||||
|
"og-cover.jpg`t1200x630`t{0} KB" -f [Math]::Round((Get-Item (Join-Path $outDir 'og-cover.jpg')).Length / 1KB)
|
||||||
|
} finally { $og.Dispose() }
|
||||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,722 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", "Noto Sans CJK SC",
|
||||||
|
sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono",
|
||||||
|
"DejaVu Sans Mono", monospace;
|
||||||
|
|
||||||
|
--bg: #fbfaf7;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
--bg-subtle: #f2f0eb;
|
||||||
|
--bg-code: #f0eee8;
|
||||||
|
--text: #1b1c20;
|
||||||
|
--text-muted: #55585f;
|
||||||
|
--text-faint: #6d7078;
|
||||||
|
--border: #ded9cf;
|
||||||
|
--border-strong: #c6c0b3;
|
||||||
|
--accent: #8a6110;
|
||||||
|
--accent-hover: #6d4b0b;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--accent-soft: #f6eeda;
|
||||||
|
--focus: #1b5ea8;
|
||||||
|
--shadow: 0 1px 2px rgba(27, 28, 32, .05), 0 8px 24px rgba(27, 28, 32, .07);
|
||||||
|
--radius: 12px;
|
||||||
|
--measure: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #12141a;
|
||||||
|
--bg-elevated: #1a1d24;
|
||||||
|
--bg-subtle: #1f232b;
|
||||||
|
--bg-code: #22262f;
|
||||||
|
--text: #f0f1f4;
|
||||||
|
--text-muted: #b3b8c4;
|
||||||
|
--text-faint: #9aa0ad;
|
||||||
|
--border: #2e333d;
|
||||||
|
--border-strong: #414753;
|
||||||
|
--accent: #e0b256;
|
||||||
|
--accent-hover: #f0c672;
|
||||||
|
--accent-contrast: #17181c;
|
||||||
|
--accent-soft: #2a2519;
|
||||||
|
--focus: #7ab6f0;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 28px rgba(0, 0, 0, .34);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
* { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.75;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
margin: 0 0 .6em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: clamp(1.9rem, 1.3rem + 2.4vw, 3rem); }
|
||||||
|
h2 { font-size: clamp(1.45rem, 1.2rem + 1.1vw, 1.95rem); }
|
||||||
|
h3 { font-size: 1.16rem; }
|
||||||
|
|
||||||
|
p, ul, ol, dl, table, figure, pre {
|
||||||
|
margin: 0 0 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, samp, pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code:not(pre code) {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: .1em .38em;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .95rem 1.05rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: .05em .4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
border-radius: 0 0 var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1080px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: clamp(1rem, 4vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--bg);
|
||||||
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||||
|
backdrop-filter: saturate(1.4) blur(10px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (backdrop-filter: blur(2px)) {
|
||||||
|
.site-header { background: var(--bg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header .wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem 1.5rem;
|
||||||
|
padding-block: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .brand-light { display: block; }
|
||||||
|
.brand .brand-dark { display: none; }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.brand .brand-light { display: none; }
|
||||||
|
.brand .brand-dark { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .2rem .35rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
display: block;
|
||||||
|
padding: .35rem .68rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a[aria-current="page"] {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { display: block; }
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding-block: clamp(2.6rem, 5vw, 4.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
section + section {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
max-width: var(--measure);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-block: clamp(2.6rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: clamp(2rem, 4vw, 3.2rem);
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.hero-grid { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p.lede {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 46ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 { margin-bottom: .5em; }
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .22rem .8rem;
|
||||||
|
font-size: .86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .7rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .62rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-center { justify-content: center; }
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot figcaption {
|
||||||
|
padding: .65rem .9rem;
|
||||||
|
font-size: .88rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .92rem;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(135deg,
|
||||||
|
var(--bg-subtle) 0 12px,
|
||||||
|
var(--bg-elevated) 12px 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin-bottom: .45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p:last-child,
|
||||||
|
.card ul:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card ul {
|
||||||
|
padding-inline-start: 1.15rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .card-meta {
|
||||||
|
display: block;
|
||||||
|
font-size: .82rem;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul, .prose ol {
|
||||||
|
padding-inline-start: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li + li {
|
||||||
|
margin-top: .35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li > strong:first-child {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-inline-start: 4px solid var(--accent);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
margin: 0 0 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.callout h3 {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
margin-bottom: .35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0;
|
||||||
|
font-size: .96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
caption-side: top;
|
||||||
|
text-align: start;
|
||||||
|
padding: .8rem 1rem 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: start;
|
||||||
|
padding: .62rem .9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
font-size: .88rem;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yes { color: var(--accent); font-weight: 700; }
|
||||||
|
.no { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
counter-reset: step;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps > li {
|
||||||
|
counter-increment: step;
|
||||||
|
position: relative;
|
||||||
|
padding-inline-start: 2.6rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps > li::before {
|
||||||
|
content: counter(step);
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
top: .18em;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps > li > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.faq {
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq > h2 {
|
||||||
|
font-size: .84rem;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin: 2rem 0 .8rem;
|
||||||
|
padding-bottom: .4rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq > h2:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.faq details {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
padding: 0 1.1rem;
|
||||||
|
margin-bottom: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 620;
|
||||||
|
padding: .85rem 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: .6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.faq summary::before {
|
||||||
|
content: "+";
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq details[open] summary::before { content: "-"; }
|
||||||
|
|
||||||
|
.faq details[open] summary {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq .faq-body {
|
||||||
|
padding: .9rem 0 .3rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq .faq-body > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
gap: .1rem 1.2rem;
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 620px) {
|
||||||
|
.kv { grid-template-columns: max-content minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dt {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dd {
|
||||||
|
margin: 0 0 .55rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .45rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-list li {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .2rem .78rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta .wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta h2 { margin-bottom: .25em; }
|
||||||
|
.cta p { margin-bottom: 0; color: var(--text-muted); }
|
||||||
|
.cta .btn-row { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
padding-block: 2.2rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer .wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.4rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer h2 {
|
||||||
|
font-size: .82rem;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer li { margin-bottom: .3rem; }
|
||||||
|
|
||||||
|
.site-footer .legal {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
margin: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center { text-align: center; }
|
||||||
|
|
||||||
|
.notfound {
|
||||||
|
min-height: 52vh;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>下载与安装 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 下载与安装说明。Windows x64 免安装版解压即用,用户数据在程序同级 data 目录;macOS arm64 提供 DMG,因为是 ad-hoc 签名未经 Apple 公证,首次打开需要右键选择打开。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/download.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="下载与安装 - PeopleLib">
|
||||||
|
<meta property="og:description" content="Windows 免安装版与 macOS DMG 的完整安装步骤,包含 macOS 首次打开的处理方法。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/download.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html" aria-current="page">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">下载</span>
|
||||||
|
<h1>获取 PeopleLib</h1>
|
||||||
|
<p>
|
||||||
|
所有正式版本都通过项目的 GitHub Releases 发布,不通过 npm 或应用商店分发。
|
||||||
|
当前版本 2.0.0,MIT 许可,免费。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="https://github.com/lofyer/peoplelib/releases/latest" rel="noopener">前往最新发布页</a>
|
||||||
|
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/releases" rel="noopener">查看全部版本</a>
|
||||||
|
</p>
|
||||||
|
<p class="hint">请只从上面的 GitHub Releases 页面下载。本站不直接托管安装包。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="windows">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">Windows</span>
|
||||||
|
<h2>Windows x64 免安装版</h2>
|
||||||
|
<p>没有安装程序,不写注册表,解压就能用。</p>
|
||||||
|
</div>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>
|
||||||
|
<p>在 Releases 页面下载 Windows x64 的压缩包。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p><strong>完整解压到一个固定目录</strong>。不要在压缩包里直接双击运行,那样程序找不到自己的资源文件。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>双击目录中的 <code>PeopleLib.exe</code> 启动。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p><strong>保留整个程序目录</strong>,不要只把 exe 移到别处。用户数据保存在程序同级的 <code>data/</code> 目录里。</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="callout">
|
||||||
|
<h3>更新到新版本</h3>
|
||||||
|
<p>
|
||||||
|
先完全退出正在运行的 <code>PeopleLib.exe</code>,再用新版本的文件覆盖程序目录。
|
||||||
|
<code>data/</code> 目录不要替换,书库、笔记与批注都在里面。想换目录的话,把整个目录连 <code>data/</code> 一起搬走。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Windows 可能对下载来的可执行文件提示来源不明,这是未购买代码签名证书的常见表现。
|
||||||
|
如果你不确定,可以先在 Releases 页面核对文件来源。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="macos">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">macOS</span>
|
||||||
|
<h2>macOS arm64 DMG</h2>
|
||||||
|
<p>适用于 Apple Silicon 机型。安装包为 ad-hoc 签名,没有经过 Apple 公证。</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="callout">
|
||||||
|
<h3>首次打开会被系统拦下,这是正常的</h3>
|
||||||
|
<p>
|
||||||
|
应用用的是 ad-hoc 签名,没有付费开发者账号,也没有走 Apple 的公证流程。
|
||||||
|
所以 macOS 首次会提示无法验证开发者,或者说文件已损坏。这不是病毒,而是未公证应用的标准提示,
|
||||||
|
按下面的步骤打开一次之后就不会再出现。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>
|
||||||
|
<p>在 Releases 页面下载 macOS arm64 的 <code>.dmg</code> 文件。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>双击挂载 DMG,把 <strong>PeopleLib</strong> 拖进「应用程序」文件夹。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
在「应用程序」里 <strong>按住 Control 点击</strong>(或右键)PeopleLib 图标,选择「打开」,
|
||||||
|
然后在弹出的对话框里再点一次「打开」。直接双击是不行的,必须走右键菜单这条路径,
|
||||||
|
因为只有它会给出确认打开的选项。
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
如果仍然被拦,打开「系统设置」,进入「隐私与安全性」,在页面下方找到刚被阻止的 PeopleLib,
|
||||||
|
点击「仍要打开」。
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
macOS 版的用户数据在 <code>~/Library/Application Support/PeopleLib</code>,不在应用包里。
|
||||||
|
升级时直接覆盖「应用程序」里的旧版本,数据不受影响。
|
||||||
|
</p>
|
||||||
|
<p class="hint">Intel 机型目前没有提供构建版本。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="first-run">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">上手</span>
|
||||||
|
<h2>第一次启动之后做什么</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<ol>
|
||||||
|
<li><strong>需要代理就先配代理</strong>。设置里配一次,对所有数据源与封面请求同时生效。</li>
|
||||||
|
<li><strong>试一次检索</strong>。可以先只选一个数据源,确认网络通畅,再切到聚合搜索。</li>
|
||||||
|
<li><strong>导入已有的书</strong>。本地已有的 PDF、EPUB 等文件可以直接导入书库,不必来自检索结果。</li>
|
||||||
|
<li><strong>想用 AI 就填自己的接口</strong>。填接口地址与 API Key,选择协议类型。不填也能正常阅读,AI 是可选的。</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="from-source">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">开发者</span>
|
||||||
|
<h2>从源码运行</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
源码运行、打包发布、代理与账号配置、数据位置、项目结构与测试说明都在源码仓库的 <code>BUILD.md</code> 里。
|
||||||
|
公开仓库只发布二进制与使用说明。
|
||||||
|
</p>
|
||||||
|
<p>运行环境要求 Node.js 22.19.0 或更高版本。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>装完遇到问题</h2>
|
||||||
|
<p>常见问题页覆盖了 macOS 拦截、数据位置与格式支持。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="faq.html">查看常见问题</a>
|
||||||
|
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/issues" rel="noopener">提交问题反馈</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>常见问题 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 常见问题:AI 助手要不要花钱、数据存在哪里、支持哪些格式、macOS 提示无法验证开发者怎么办、能不能不联网使用、超大 PDF 会不会卡。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/faq.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="常见问题 - PeopleLib">
|
||||||
|
<meta property="og:description" content="AI 费用、数据位置、格式支持、macOS 首次打开被拦、离线使用与超大 PDF 的处理方式。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/faq.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html" aria-current="page">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">常见问题</span>
|
||||||
|
<h1>常见问题</h1>
|
||||||
|
<p>点击问题展开答案。没有覆盖到的情况可以到仓库提 issue。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq">
|
||||||
|
|
||||||
|
<h2>费用与账号</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>PeopleLib 本身要钱吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>不要。项目以 MIT 许可开源,没有付费版、订阅或功能解锁。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>AI 助手要不要花钱</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
取决于你自己接的服务。应用不内置任何厂商的 AI 服务,也不代收任何费用。
|
||||||
|
你在设置里填自己的接口地址与 API Key,费用由你和那家服务商结算,账单也在他们那边看。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
如果你接的是本地跑的模型,或者某个免费额度内的接口,那就不花钱。
|
||||||
|
不填 AI 配置的话,检索、书库与阅读功能都能正常使用,AI 是可选的。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>需要注册账号吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
PeopleLib 自己没有账号体系,不需要注册也不需要登录。
|
||||||
|
个别数据源(例如需要登录才能取下载直链的站点)要用你自己在那个站点的凭据,凭据保存在本地。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>数据与隐私</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>我的数据存在哪里</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>全部在本机。书库条目、下载的文件、封面缓存、笔记、批注与阅读进度都写在本地数据目录里,没有云端副本。</p>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Windows</dt>
|
||||||
|
<dd>程序目录同级的 <code>data/</code></dd>
|
||||||
|
<dt>macOS</dt>
|
||||||
|
<dd><code>~/Library/Application Support/PeopleLib</code></dd>
|
||||||
|
</dl>
|
||||||
|
<p>备份就是复制这个目录。彻底删除就是删掉它,不需要在任何地方注销。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>我的书和笔记会被上传吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
不会。应用没有服务端,也没有遥测上报。唯一会把正文内容发出去的情况是你主动使用 AI 助手并确认发送,
|
||||||
|
目标是你自己填写的那个接口地址。详见<a href="privacy.html">隐私与安全</a>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>AI 会不会把整本书悄悄发出去</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
不会。上下文范围由你选,选「全文」时无论文档多短都强制弹确认框,并显示确切字数。
|
||||||
|
界面上显示的字数就是真正外发的字数,应用不在本地偷偷截断,也不会在你没确认时发送。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>我的 API Key 安全吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
Key 经操作系统提供的 safeStorage 加密后落盘,不是明文配置文件,也不会进入界面进程,
|
||||||
|
日志与错误信息里也不会出现。请求由主进程直接发往你填写的地址,中间没有第三方转发。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>能完全离线使用吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
可以。已经下载进书库的文件,阅读、批注与笔记都不需要网络。封面已经缓存在本地,离线也能正常显示。
|
||||||
|
需要联网的只有检索、下载、AI 请求与检查更新这四件事。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>格式与阅读</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>支持哪些格式</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>内置阅读支持四类:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>PDF</strong>:页面批注、书签、选文与笔记,渲染画质可调</li>
|
||||||
|
<li><strong>EPUB</strong>:目录、重排、书签、选文与笔记</li>
|
||||||
|
<li><strong>MOBI / AZW / AZW3</strong>:无 DRM 的 MOBI、KF7 与 KF8 内容</li>
|
||||||
|
<li><strong>TXT / Markdown</strong>:自动识别编码,Markdown 渲染标题、列表、代码块与表格</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<strong>DJVU、FB2、CBZ、CBR</strong> 可以入库与整理,但没有内置阅读器,阅读要调用系统关联应用。
|
||||||
|
完整对照表在<a href="index.html#formats">首页</a>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Kindle 买的书能读吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
带 DRM 的文件不能。PeopleLib 不解除 DRM,遇到受保护的 MOBI、AZW、AZW3、KFX 或 Topaz 文件会明确拒绝,
|
||||||
|
并提示改用系统关联的应用打开。无 DRM 的 MOBI、KF7 与 KF8 可以正常阅读。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>几百兆的 PDF 会不会卡死</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
超大 PDF 走分段读取,不会把整个文件读进内存,所以打开一本很大的书不会一次性吃掉几百兆内存。
|
||||||
|
如果渲染感觉吃力,可以在底栏把画质档位调低。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>TXT 打开是乱码怎么办</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
应用会自动探测 UTF-8、带 BOM 与不带 BOM 的 UTF-16,以及 GB18030。
|
||||||
|
如果仍然乱码,说明文件用了这几种之外的编码,可以先用文本编辑器另存为 UTF-8 再导入。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>笔记能导出吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>可以。摘录与笔记支持导出,方便带到别的工具里继续整理。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>安装与运行</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>macOS 提示无法验证开发者,或者说文件已损坏</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
这是未公证应用的标准提示,不是文件损坏,也不是病毒。macOS 版用的是 ad-hoc 签名,
|
||||||
|
没有付费开发者账号,也没有走 Apple 的公证流程,所以系统首次会拦下来。
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>先把 PeopleLib 拖进「应用程序」文件夹。</li>
|
||||||
|
<li><strong>按住 Control 点击</strong>(或右键)应用图标,选择「打开」,在弹出的对话框里再点一次「打开」。直接双击不行,必须走右键菜单,只有这条路径会给出确认选项。</li>
|
||||||
|
<li>如果仍被拦,打开「系统设置」,进入「隐私与安全性」,在页面下方找到被阻止的 PeopleLib,点击「仍要打开」。</li>
|
||||||
|
</ol>
|
||||||
|
<p>成功打开一次之后系统会记住这个选择,之后正常双击即可。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Windows 版怎么安装</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
没有安装程序。下载压缩包后<strong>完整解压</strong>到一个固定目录,双击里面的 <code>PeopleLib.exe</code> 就能用。
|
||||||
|
不要在压缩包里直接双击运行,也不要把 exe 单独移出来,程序需要同目录下的资源文件。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>更新版本会不会丢数据</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
不会,但要注意操作顺序。Windows 上先完全退出正在运行的 <code>PeopleLib.exe</code>,再用新文件覆盖程序目录,
|
||||||
|
<code>data/</code> 目录保持原样不要替换。macOS 上数据在 <code>~/Library/Application Support/PeopleLib</code>,
|
||||||
|
直接覆盖「应用程序」里的旧版本即可。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>能换程序目录或者搬到别的电脑吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
Windows 版是便携布局,把整个程序目录连同 <code>data/</code> 一起复制走就行,
|
||||||
|
不要只搬 exe,也不要把 <code>data/</code> 落在旧目录里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>搜不到结果或者下载失败</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
数据源的可用性取决于站点自身状态。有的站点启用了人机验证,有的接口只提供最新列表而不支持关键词搜索,
|
||||||
|
有的下载需要该站点的账号。应用遇到这些情况会给出明确提示。
|
||||||
|
</p>
|
||||||
|
<p>如果所有源都不通,先检查设置里的代理配置,它对所有数据源与封面请求统一生效。</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>有 Linux 版、移动端或浏览器插件吗</summary>
|
||||||
|
<div class="faq-body">
|
||||||
|
<p>
|
||||||
|
目前发布的是 Windows x64 免安装版与 macOS arm64 DMG。没有移动端应用,也没有浏览器插件。
|
||||||
|
项目源码开放,其他平台可自行尝试从源码运行。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>问题没解决</h2>
|
||||||
|
<p>到仓库开一个 issue,说明系统版本与复现步骤。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="https://github.com/lofyer/peoplelib/issues" rel="noopener">提交问题反馈</a>
|
||||||
|
<a class="btn btn-secondary" href="download.html">回到下载页</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>功能 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 的完整功能说明:12 个数据源统一检索、本地书库与书架标签、PDF/EPUB/MOBI/TXT 内置阅读器、读书笔记与画布笔记、自带密钥的 AI 助手、全局代理与镜像故障转移。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/features.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="功能 - PeopleLib">
|
||||||
|
<meta property="og:description" content="多源检索、本地书库、内置阅读器、笔记与批注、AI 助手、代理与镜像容错的完整说明。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/features.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html" aria-current="page">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">功能</span>
|
||||||
|
<h1>已经实现的部分</h1>
|
||||||
|
<p>这一页只写应用里真实存在的能力。没有列出的功能就是没有做。</p>
|
||||||
|
</div>
|
||||||
|
<ul class="pill-list">
|
||||||
|
<li><a href="#search">多源检索</a></li>
|
||||||
|
<li><a href="#library">本地书库</a></li>
|
||||||
|
<li><a href="#reader">内置阅读器</a></li>
|
||||||
|
<li><a href="#notes">笔记与批注</a></li>
|
||||||
|
<li><a href="#ai">AI 助手</a></li>
|
||||||
|
<li><a href="#network">代理与镜像</a></li>
|
||||||
|
<li><a href="#updates">版本更新</a></li>
|
||||||
|
<li><a href="#not-included">不包含的功能</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="search">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">检索</span>
|
||||||
|
<h2>12 个数据源,一套流程</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
检索、查看详情、下载文件这三步对所有数据源都是同一套界面与同一套交互。可以只搜某一个源,
|
||||||
|
也可以聚合全部源一起搜。不同站点的字段差异由应用内部归一,你看到的是统一的条目列表。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
数据源的可用性取决于站点自身状态。有的站点启用了人机验证,有的接口只提供最新列表而不支持关键词搜索。
|
||||||
|
遇到这类限制时应用会给出明确提示,不会假装搜索成功却返回空结果。完整的数据源清单见
|
||||||
|
<a href="https://github.com/lofyer/peoplelib" rel="noopener">仓库说明</a>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-search.jpg" width="1600" height="1086" alt="多源检索界面,顶部是搜索框与数据源选择,下方列出检索结果条目" loading="lazy">
|
||||||
|
<figcaption>检索结果与数据源筛选</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="library">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">归档</span>
|
||||||
|
<h2>本地书库</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>收藏与下载</dt>
|
||||||
|
<dd>检索到的条目可以先收藏,再决定是否下载文件。下载完成后自动登记进书库。</dd>
|
||||||
|
<dt>封面缓存</dt>
|
||||||
|
<dd>封面下载后缓存在本地,离线也能正常显示网格视图。</dd>
|
||||||
|
<dt>书架与标签</dt>
|
||||||
|
<dd>用书架做粗分类,用标签做交叉归类,同一本书可以属于多个标签。</dd>
|
||||||
|
<dt>批量整理</dt>
|
||||||
|
<dd>支持多选,批量移动书架、批量打标签、批量删除。</dd>
|
||||||
|
<dt>阅读状态</dt>
|
||||||
|
<dd>记录每本书的阅读状态与进度,重新打开时回到上次的位置。</dd>
|
||||||
|
<dt>本地导入</dt>
|
||||||
|
<dd>已有的文件可以直接导入书库,不必来自检索结果。</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="reader">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">阅读</span>
|
||||||
|
<h2>内置阅读器</h2>
|
||||||
|
<p>四类格式在应用内直接打开,不需要外部程序。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<h3>PDF</h3>
|
||||||
|
<ul>
|
||||||
|
<li>页面批注、书签、选文与笔记</li>
|
||||||
|
<li>渲染画质分三档可调并记住选择</li>
|
||||||
|
<li>超大文件分段读取,不整文件载入内存</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>EPUB</h3>
|
||||||
|
<ul>
|
||||||
|
<li>目录导航与文字重排</li>
|
||||||
|
<li>书签、选文与笔记</li>
|
||||||
|
<li>按章节与偏移量记录阅读位置</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>MOBI / AZW / AZW3</h3>
|
||||||
|
<ul>
|
||||||
|
<li>使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8</li>
|
||||||
|
<li>DRM 保护的文件明确拒绝,不尝试绕过</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>TXT / Markdown</h3>
|
||||||
|
<ul>
|
||||||
|
<li>自动识别 UTF-8、UTF-16 与 GB18030 编码</li>
|
||||||
|
<li>Markdown 渲染标题、列表、代码块与表格</li>
|
||||||
|
<li>与 EPUB 同一套重排与定位机制</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-reader.jpg" width="1600" height="1050" alt="内置 PDF 阅读器,页面上带有高亮批注,右侧是笔记面板" loading="lazy">
|
||||||
|
<figcaption>PDF 阅读与批注</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="notes">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">批注</span>
|
||||||
|
<h2>笔记与批注</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
批注直接落在页面上,和书目条目绑定。笔记分两种形态:读书笔记是文字为主的富文本,
|
||||||
|
画布笔记可以自由绘制,并且能把 PDF 的某一页当作底版,在上面圈画标记。
|
||||||
|
</p>
|
||||||
|
<p>选中的段落可以摘录进笔记,笔记与摘录支持导出,方便带到别的工具里继续用。</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-annotations.jpg" width="1600" height="1058" alt="PDF 页面上的批注工具与已有的高亮标记" loading="lazy">
|
||||||
|
<figcaption>批注工具</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ai">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">AI</span>
|
||||||
|
<h2>自带密钥的 AI 助手</h2>
|
||||||
|
<p>应用不内置任何厂商服务,接口地址与密钥都由你填。</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>支持三类协议:Anthropic 的 <code>/messages</code>、OpenAI Responses 的 <code>/responses</code>,以及兼容 Chat 的 <code>/chat/completions</code>。任何符合这三类协议的服务都能接。</p>
|
||||||
|
<h3>五种上下文范围</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>选中文本</strong>:只发你选中的那一段,需要先选中。</li>
|
||||||
|
<li><strong>当前页</strong>:发当前页的正文,不依赖选区。</li>
|
||||||
|
<li><strong>全文</strong>:发整篇正文,不依赖选区,发送前强制确认。</li>
|
||||||
|
<li><strong>页面图像</strong>:把当前页渲染成图片发送,适合公式与图表。</li>
|
||||||
|
<li><strong>框选区域</strong>:在页面上拉一个框,只发框内的图像。</li>
|
||||||
|
</ul>
|
||||||
|
<h3>关于字数</h3>
|
||||||
|
<p>
|
||||||
|
正文完整发送,本地不做截断。界面上显示的字数就是真正外发的字数,两者一致。
|
||||||
|
如果超出模型的上下文限制,应用会把接口返回的报错转成中文提示,而不是悄悄少发一部分内容。
|
||||||
|
</p>
|
||||||
|
<p>对话支持多轮追问,上下文范围在会话中可以随时切换。</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai.jpg" width="1474" height="1125" alt="AI 助手侧栏,展示上下文范围选择与对话内容" loading="lazy">
|
||||||
|
<figcaption>AI 助手侧栏与上下文范围选择</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="network">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">网络</span>
|
||||||
|
<h2>代理与镜像容错</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>全局代理</dt>
|
||||||
|
<dd>在设置里配一次,对所有数据源请求与封面请求同时生效,不需要逐个源单独设置。</dd>
|
||||||
|
<dt>镜像故障转移</dt>
|
||||||
|
<dd>某个镜像不可用时自动切到下一个,原镜像恢复后自动重新启用,不需要手动重置。</dd>
|
||||||
|
<dt>失败提示</dt>
|
||||||
|
<dd>站点侧的人机验证、登录过期与超时都会转成明确的中文提示。</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="updates">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">维护</span>
|
||||||
|
<h2>版本更新</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
可以在设置里手动检查更新,也可以打开启动时自动检查。检查的对象是项目的 GitHub Releases,
|
||||||
|
发现新版本后应用会打开对应的下载页。更新前退出旧版本再覆盖程序文件,用户数据目录不需要替换。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="not-included">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">边界</span>
|
||||||
|
<h2>不包含的功能</h2>
|
||||||
|
<p>写清楚没有什么,比含糊其辞更省你的时间。</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<ul>
|
||||||
|
<li>没有云同步,也没有账号体系。</li>
|
||||||
|
<li>没有移动端应用,也没有浏览器插件。</li>
|
||||||
|
<li>没有多人协作或共享书库。</li>
|
||||||
|
<li>DJVU、FB2、CBZ、CBR 只能入库与整理,阅读要调用系统关联应用。</li>
|
||||||
|
<li>不解除 DRM。受保护的文件会被明确拒绝并提示改用系统关联应用。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>看完想试试</h2>
|
||||||
|
<p>下载页有 Windows 与 macOS 的完整安装说明。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="download.html">前往下载</a>
|
||||||
|
<a class="btn btn-secondary" href="privacy.html">先看隐私说明</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PeopleLib:多源文献检索、本地书库与内置阅读器</title>
|
||||||
|
<meta name="description" content="PeopleLib 是一款桌面文献客户端,在一个界面里检索 12 个公开文献源,下载归档到本地书库,并用内置阅读器阅读 PDF、EPUB、MOBI 与 TXT/Markdown。书库、笔记与批注全部存在本地,AI 助手自带密钥。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="PeopleLib:多源文献检索、本地书库与内置阅读器">
|
||||||
|
<meta property="og:description" content="在一个桌面客户端里检索 12 个公开文献源,下载归档到本地书库,用内置阅读器阅读并批注。数据留在本地,AI 助手自带密钥。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="PeopleLib:多源文献检索、本地书库与内置阅读器">
|
||||||
|
<meta name="twitter:description" content="在一个桌面客户端里检索 12 个公开文献源,下载归档到本地书库,用内置阅读器阅读并批注。数据留在本地,AI 助手自带密钥。">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html" aria-current="page">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div class="wrap hero-grid">
|
||||||
|
<div>
|
||||||
|
<p class="tagline">Windows 与 macOS 桌面应用</p>
|
||||||
|
<h1>检索、收藏、阅读,都在自己的电脑里完成</h1>
|
||||||
|
<p class="lede">
|
||||||
|
PeopleLib 把 12 个公开文献源的检索、详情与下载收进同一个界面,下载的文件直接进本地书库,
|
||||||
|
用内置阅读器打开并做批注。书库、笔记与阅读进度都留在你的磁盘上,不经过任何服务器。
|
||||||
|
</p>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="download.html">下载 PeopleLib</a>
|
||||||
|
<a class="btn btn-secondary" href="features.html">查看功能</a>
|
||||||
|
</p>
|
||||||
|
<p class="hint">当前版本 2.0.0,MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-library.jpg" width="1600" height="1086" alt="PeopleLib 书库界面,左侧是书架与标签,右侧网格展示带封面的图书条目" fetchpriority="high">
|
||||||
|
<figcaption>本地书库:书架、标签、封面缓存与阅读状态</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="highlights">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">核心能力</span>
|
||||||
|
<h2>一条完整的链路,从找到文献到读完做笔记</h2>
|
||||||
|
<p>没有账号,没有云端,没有订阅。每一步都在本机完成。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">检索</span>
|
||||||
|
<h3>多源统一检索</h3>
|
||||||
|
<p>12 个数据源共用同一套搜索、详情与下载流程,可指定单个源,也可聚合全部。数据源可用性取决于站点自身状态,程序会给出明确提示而不是静默失败。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">归档</span>
|
||||||
|
<h3>本地书库</h3>
|
||||||
|
<p>收藏条目、下载文件、缓存封面,用书架与标签整理,支持多选批量操作,并记录每本书的阅读状态。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">阅读</span>
|
||||||
|
<h3>内置阅读器</h3>
|
||||||
|
<p>PDF、EPUB、无 DRM 的 MOBI/AZW/AZW3 与 TXT/Markdown 都在应用内打开,保留进度、书签与选文。超大 PDF 分段读取,不把整个文件读进内存。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">批注</span>
|
||||||
|
<h3>笔记与批注</h3>
|
||||||
|
<p>读书笔记与画布笔记两种形态,画布笔记可以拿 PDF 页面当底版自由标注,摘录与笔记可导出。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">AI</span>
|
||||||
|
<h3>自带密钥的 AI 助手</h3>
|
||||||
|
<p>填自己的接口地址与 API Key,支持选中文本、当前页、全文、页面图像与框选区域五种上下文范围,可多轮追问。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="card-meta">网络</span>
|
||||||
|
<h3>代理与镜像容错</h3>
|
||||||
|
<p>代理一处配置对所有数据源与封面请求生效。镜像失效自动切换,恢复后自动重新启用。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="privacy-teaser">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">设计取舍</span>
|
||||||
|
<h2>本地优先不是宣传语,是架构约束</h2>
|
||||||
|
<p>这些限制写在代码的边界里,不是可以在设置里关掉的开关。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<h3>数据不出本机</h3>
|
||||||
|
<p>书库、笔记、批注与阅读进度都存在本地文件里。应用没有账号体系,也没有同步服务端。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>AI 密钥由系统加密保管</h3>
|
||||||
|
<p>没有内置任何厂商服务。API Key 经操作系统的 safeStorage 加密后落盘,不进渲染进程,不写日志。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>正文不会自动外发</h3>
|
||||||
|
<p>只有你确认后才发送。选择「全文」范围时无论文档多短都强制弹确认框,并显示确切字数。</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>渲染层拿不到任意路径</h3>
|
||||||
|
<p>界面进程不能直接读盘,所有文件访问都要经主进程白名单校验,只放行书库中真实登记的条目。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p><a href="privacy.html">阅读完整的隐私与安全说明</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screens">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">界面预览</span>
|
||||||
|
<h2>真实截图</h2>
|
||||||
|
<p>以下画面来自 Windows 版本,界面为中文。</p>
|
||||||
|
</div>
|
||||||
|
<div class="gallery">
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-search.jpg" width="1600" height="1086" alt="多源检索界面,顶部是搜索框与数据源选择,下方列出检索结果条目" loading="lazy">
|
||||||
|
<figcaption>多源检索,可选单个数据源或聚合全部</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-reader.jpg" width="1600" height="1050" alt="内置 PDF 阅读器,页面上带有高亮批注,右侧是笔记面板" loading="lazy">
|
||||||
|
<figcaption>内置阅读器与 PDF 批注</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai.jpg" width="1474" height="1125" alt="AI 助手侧栏,展示上下文范围选择与对话内容" loading="lazy">
|
||||||
|
<figcaption>AI 助手,可选五种上下文范围提问</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai-confirm.jpg" width="1600" height="1076" alt="发送前的确认对话框,显示即将外发的字数" loading="lazy">
|
||||||
|
<figcaption>发送前的确认框会显示确切字数</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="formats">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">格式</span>
|
||||||
|
<h2>能读什么,能管什么</h2>
|
||||||
|
<p>入库管理与内置阅读是两件事,下表分开列出,不夸大支持范围。</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<caption>格式支持对照</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">格式</th>
|
||||||
|
<th scope="col">入库与管理</th>
|
||||||
|
<th scope="col">内置阅读</th>
|
||||||
|
<th scope="col">说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">PDF</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>页面批注、书签、选文与笔记,可调渲染画质</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">EPUB</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>目录、重排、书签、选文与笔记</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">MOBI / AZW / AZW3</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">TXT / Markdown</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td>自动识别编码,Markdown 渲染标题、列表、代码块与表格</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">DJVU / FB2 / CBZ / CBR</th>
|
||||||
|
<td><span class="yes">支持</span></td>
|
||||||
|
<td><span class="no">不支持</span></td>
|
||||||
|
<td>可入库整理,阅读需调用系统关联应用</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint">DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,应用会明确提示改用系统关联应用打开。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>准备好开始了</h2>
|
||||||
|
<p>Windows 免安装解压即用,macOS 提供 arm64 DMG。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="download.html">前往下载</a>
|
||||||
|
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/releases" rel="noopener">GitHub Releases</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html">隐私与安全</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>隐私与安全 - PeopleLib</title>
|
||||||
|
<meta name="description" content="PeopleLib 的隐私与安全设计:书库笔记批注全部留在本地,AI 助手完全自带密钥且密钥经系统 safeStorage 加密,正文只在用户确认后外发,渲染进程无法读取任意路径,不解除 DRM。">
|
||||||
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="canonical" href="https://reader.mesalogo.com/privacy.html">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="PeopleLib">
|
||||||
|
<meta property="og:locale" content="zh_CN">
|
||||||
|
<meta property="og:title" content="隐私与安全 - PeopleLib">
|
||||||
|
<meta property="og:description" content="数据留在本地,AI 自带密钥,正文不自动外发,渲染层无法读取任意路径。这些是架构约束,不是可选开关。">
|
||||||
|
<meta property="og:url" content="https://reader.mesalogo.com/privacy.html">
|
||||||
|
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="index.html">
|
||||||
|
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
|
||||||
|
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
|
||||||
|
<span>PeopleLib</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav" aria-label="站点主导航">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="privacy.html" aria-current="page">隐私</a></li>
|
||||||
|
<li><a href="download.html">下载</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">隐私与安全</span>
|
||||||
|
<h1>你的书库只属于你</h1>
|
||||||
|
<p>
|
||||||
|
下面这些不是隐私政策的模板文字,而是应用实际的架构约束。它们决定了某些事情在这个程序里做不到,
|
||||||
|
哪怕以后想加也要先推翻现有设计。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="callout">
|
||||||
|
<h3>一句话总结</h3>
|
||||||
|
<p>
|
||||||
|
PeopleLib 没有服务端。它唯一会主动联网的场合是你按下搜索或下载,以及你启用检查更新。
|
||||||
|
AI 请求发往你自己填的接口地址。除此之外没有任何数据外发。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="local-first">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">本地优先</span>
|
||||||
|
<h2>数据存在哪里</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>书库条目、下载的文件、封面缓存、读书笔记、画布笔记、批注与阅读进度,全部写在本机的数据目录里。没有账号,没有同步服务端,没有遥测上报。</p>
|
||||||
|
<p>AI 对话记录同样只存在本机,按会话分别存成文件,随书籍删除一并清理。发给视觉模型的图像也留在本地,供你回看历史时显示,后续追问不会重复上传。</p>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Windows</dt>
|
||||||
|
<dd>程序目录同级的 <code>data/</code>,便携布局。整个程序目录可以整体搬走或备份。</dd>
|
||||||
|
<dt>macOS</dt>
|
||||||
|
<dd><code>~/Library/Application Support/PeopleLib</code>。应用包在 DMG 里是只读的,数据不能放里面,升级覆盖也不会碰到这个目录。</dd>
|
||||||
|
</dl>
|
||||||
|
<p>想彻底删除数据,删掉上面对应的目录即可,不需要在任何地方注销账号。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ai-key">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">AI 密钥</span>
|
||||||
|
<h2>完全自带密钥,没有中间人</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
应用没有内置任何厂商服务,也没有代理转发层。你在设置里填自己的接口地址与 API Key,
|
||||||
|
请求从你的机器直接发到你填的那个地址。项目方看不到你的用量,也收不到你的费用。
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>密钥加密落盘</strong>:API Key 经操作系统提供的 safeStorage 加密后保存,不是明文配置文件。</li>
|
||||||
|
<li><strong>密钥不进渲染进程</strong>:界面进程拿不到密钥原文,实际请求由主进程发出。</li>
|
||||||
|
<li><strong>密钥不写日志</strong>:日志与错误信息里不会出现密钥或 token。</li>
|
||||||
|
<li><strong>Responses 协议不留存</strong>:走 OpenAI Responses 协议时请求带 <code>store: false</code>,不让服务端留存对话记录。</li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">注意:你选择的模型提供方会按它自己的政策处理收到的内容。PeopleLib 能控制的是不多发、不留副本、不经第三方转发。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="explicit-send">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">明确确认</span>
|
||||||
|
<h2>正文不会自动外发</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
打开一本书不会触发任何 AI 请求。只有你选定上下文范围并点击发送,内容才会离开本机。
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>选择「全文」范围时,无论文档多短都会强制弹出确认框,并提示可能超出模型的上下文限制。</li>
|
||||||
|
<li>确认框里显示的字数就是真正外发的字数。应用不做本地截断,界面数字与实际请求一致。</li>
|
||||||
|
<li>如果内容超出模型限制,应用把接口返回的报错转成中文提示,而不是悄悄少发一部分。</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
这条规则有过教训。早期版本按固定字数上限挖掉正文中间部分,结果 40 页的文档实际只发出 8 页,
|
||||||
|
而界面仍显示全文字数。那属于静默的数据丢失,用户还会把答非所问归因于模型,所以该行为已经移除。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<figure class="shot">
|
||||||
|
<img src="assets/shot-ai-confirm.jpg" width="1600" height="1076" alt="发送前的确认对话框,显示即将外发的字数与超出上下文限制的提示" loading="lazy">
|
||||||
|
<figcaption>发送确认框</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sandbox">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">进程边界</span>
|
||||||
|
<h2>界面进程读不到任意文件</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
Electron 应用最常见的风险是渲染进程能拿着任意路径去读盘。PeopleLib 把这条路堵住了:
|
||||||
|
渲染层不能传任意路径,所有文件访问都要经主进程的白名单校验,只放行书库中真实登记的条目。
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>路径白名单</strong>:不在书库里的路径一律拒绝,无论渲染层怎么构造参数。</li>
|
||||||
|
<li><strong>会话与发送方绑定</strong>:超大 PDF 的分段读取会话与请求方绑定,句柄不透明,界面窗口销毁后立即回收。</li>
|
||||||
|
<li><strong>Markdown 双重净化</strong>:Markdown 渲染禁用裸 HTML,再过一层 DOMPurify,防止文件内容里的脚本被执行。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="drm">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">合规</span>
|
||||||
|
<h2>不绕过 DRM</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
带 DRM 的 MOBI、AZW、AZW3、KFX 与 Topaz 文件,以及损坏或不兼容的文件,应用不会尝试解除保护。
|
||||||
|
遇到这类文件会直接给出提示,建议改用系统关联的应用打开。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
同样地,PeopleLib 只是对公开网络接口的客户端封装,不托管也不分发任何内容。
|
||||||
|
各数据源所提供作品的版权状态因司法辖区而异,使用者需自行确保用法符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="network-scope">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">联网范围</span>
|
||||||
|
<h2>什么时候会联网</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<caption>应用发起网络请求的全部场合</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">场合</th>
|
||||||
|
<th scope="col">目标</th>
|
||||||
|
<th scope="col">触发方式</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">检索与下载</th>
|
||||||
|
<td>你选择的数据源站点</td>
|
||||||
|
<td>你点击搜索或下载</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">封面加载</th>
|
||||||
|
<td>条目对应的封面地址</td>
|
||||||
|
<td>展示书库或检索结果</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">AI 请求</th>
|
||||||
|
<td>你自己填写的接口地址</td>
|
||||||
|
<td>你确认发送</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">检查更新</th>
|
||||||
|
<td>项目的 GitHub Releases</td>
|
||||||
|
<td>手动检查,或你启用了启动时检查</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p>所有这些请求都走你在设置里配置的全局代理,包括封面请求。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="site-privacy">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">本站点</span>
|
||||||
|
<h2>这个网站本身</h2>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
本站是托管在 GitHub Pages 上的静态页面,没有分析脚本,没有 Cookie,没有第三方字体或 CDN 资源,
|
||||||
|
页面用的是你系统里已有的字体。作为托管方,GitHub 会按其自身政策记录访问日志,这一点本站无法控制。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>还有疑问</h2>
|
||||||
|
<p>常见问题里覆盖了费用、数据位置、格式支持与 macOS 首次打开。</p>
|
||||||
|
</div>
|
||||||
|
<p class="btn-row">
|
||||||
|
<a class="btn btn-primary" href="faq.html">查看常见问题</a>
|
||||||
|
<a class="btn btn-secondary" href="download.html">下载与安装</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h2>站点</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">首页</a></li>
|
||||||
|
<li><a href="features.html">功能</a></li>
|
||||||
|
<li><a href="download.html">下载与安装</a></li>
|
||||||
|
<li><a href="faq.html">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>项目</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
|
||||||
|
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>版本</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PeopleLib 2.0.0</li>
|
||||||
|
<li>MIT 许可</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="legal">
|
||||||
|
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
|
||||||
|
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://reader.mesalogo.com/sitemap.xml
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/features.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/privacy.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/download.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://reader.mesalogo.com/faq.html</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const sessionsPath = require.resolve('../reader/ai-sessions.js');
|
||||||
|
const imagesPath = require.resolve('../reader/ai-images.js');
|
||||||
|
|
||||||
|
const dirs = [];
|
||||||
|
|
||||||
|
function tmp(tag) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
|
||||||
|
dirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fresh(tag = 'x') {
|
||||||
|
const dir = tmp(tag);
|
||||||
|
return at(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function at(dir) {
|
||||||
|
delete require.cache[sessionsPath];
|
||||||
|
delete require.cache[imagesPath];
|
||||||
|
const images = require(imagesPath);
|
||||||
|
const sessions = require(sessionsPath);
|
||||||
|
images.init(dir);
|
||||||
|
sessions.init(dir);
|
||||||
|
return { sessions, images, dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionDir(dir) {
|
||||||
|
return path.join(dir, 'reader-ai-sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionFile(dir, id) {
|
||||||
|
return path.join(sessionDir(dir), `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageDir(dir) {
|
||||||
|
return path.join(dir, 'reader-ai-images');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最小可解码 JPEG:SOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
|
||||||
|
function jpeg(seed, size = 64) {
|
||||||
|
const bytes = Buffer.alloc(size, seed & 0xff);
|
||||||
|
bytes[0] = 0xff;
|
||||||
|
bytes[1] = 0xd8;
|
||||||
|
bytes[2] = 0xff;
|
||||||
|
bytes[3] = 0xe0;
|
||||||
|
bytes[size - 2] = 0xff;
|
||||||
|
bytes[size - 1] = 0xd9;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
|
||||||
|
function countWrites(matcher, fn) {
|
||||||
|
const originalOpen = fs.openSync;
|
||||||
|
let writes = 0;
|
||||||
|
fs.openSync = function counting(target, flags, ...rest) {
|
||||||
|
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
|
||||||
|
return originalOpen.call(this, target, flags, ...rest);
|
||||||
|
};
|
||||||
|
try { fn(); } finally { fs.openSync = originalOpen; }
|
||||||
|
return writes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(s, id, question, answer) {
|
||||||
|
s.appendUser(id, { text: question, task: 'ask' });
|
||||||
|
const placeholder = s.appendAssistant(id, {});
|
||||||
|
return s.finishAssistant(id, placeholder.id, { text: answer });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 基本读写 ---
|
||||||
|
|
||||||
|
test('会话按 per-session 文件落盘,索引可枚举', () => {
|
||||||
|
const { sessions, dir } = fresh('basic');
|
||||||
|
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
|
||||||
|
const b = sessions.create({ entryId: 'e2' });
|
||||||
|
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
|
||||||
|
assert.strictEqual(a.entryId, 'e1');
|
||||||
|
assert.strictEqual(b.entryId, 'e2');
|
||||||
|
const files = fs.readdirSync(sessionDir(dir)).sort();
|
||||||
|
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
|
||||||
|
'每个会话应独占一个文件,加上一份索引');
|
||||||
|
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
|
||||||
|
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
|
||||||
|
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
|
||||||
|
const { sessions } = fresh('global');
|
||||||
|
const g = sessions.create({});
|
||||||
|
assert.strictEqual(g.entryId, 'system:global-chat');
|
||||||
|
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
|
||||||
|
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list 按 pinned desc, updatedAt desc 排序', () => {
|
||||||
|
const { sessions } = fresh('sort');
|
||||||
|
const a = sessions.create({ title: 'a' });
|
||||||
|
const b = sessions.create({ title: 'b' });
|
||||||
|
const c = sessions.create({ title: 'c' });
|
||||||
|
sessions.rename(b.id, 'b2');
|
||||||
|
sessions.setPinned(a.id, true);
|
||||||
|
const ids = sessions.list().map((row) => row.id);
|
||||||
|
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
|
||||||
|
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
|
||||||
|
const { sessions } = fresh('round');
|
||||||
|
const meta = sessions.create({ entryId: 'e1' });
|
||||||
|
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
|
||||||
|
const placeholder = sessions.appendAssistant(meta.id, {});
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
|
||||||
|
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
|
||||||
|
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
|
||||||
|
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
|
||||||
|
const read = sessions.messages(meta.id);
|
||||||
|
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
|
||||||
|
assert.strictEqual(read.messages[1].text, '这是回答');
|
||||||
|
assert.strictEqual(read.meta.messageCount, 2);
|
||||||
|
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
|
||||||
|
const { sessions } = fresh('finish');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: 'q' });
|
||||||
|
const p1 = sessions.appendAssistant(meta.id, {});
|
||||||
|
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
|
||||||
|
assert.strictEqual(cancelled.cancelled, true);
|
||||||
|
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
|
||||||
|
sessions.appendUser(meta.id, { text: 'q2' });
|
||||||
|
const p2 = sessions.appendAssistant(meta.id, {});
|
||||||
|
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
|
||||||
|
assert.strictEqual(failed.error, '接口超时');
|
||||||
|
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
|
||||||
|
const { sessions, dir } = fresh('lifecycle');
|
||||||
|
const meta = sessions.create({ entryId: 'e1', title: '保留' });
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const cleared = sessions.clear(meta.id);
|
||||||
|
assert.strictEqual(cleared.messageCount, 0);
|
||||||
|
assert.strictEqual(cleared.droppedMessages, 0);
|
||||||
|
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
|
||||||
|
assert.strictEqual(sessions.list().length, 1);
|
||||||
|
assert.strictEqual(sessions.remove(meta.id), true);
|
||||||
|
assert.strictEqual(sessions.remove(meta.id), false);
|
||||||
|
assert.strictEqual(sessions.list().length, 0);
|
||||||
|
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
|
||||||
|
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('messages 支持 limit 与 before 游标翻页', () => {
|
||||||
|
const { sessions } = fresh('paging');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < 5; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
const tail = sessions.messages(meta.id, { limit: 4 });
|
||||||
|
assert.strictEqual(tail.messages.length, 4);
|
||||||
|
assert.strictEqual(tail.hasMore, true);
|
||||||
|
assert.strictEqual(tail.messages[3].text, '答4');
|
||||||
|
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
|
||||||
|
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
|
||||||
|
'before 必须是排他的,否则翻页会重复一条');
|
||||||
|
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 字段限长 ---
|
||||||
|
|
||||||
|
test('受限字段超长时截断而不是抛错', () => {
|
||||||
|
const { sessions } = fresh('limits');
|
||||||
|
const L = sessions.LIMITS;
|
||||||
|
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
|
||||||
|
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
|
||||||
|
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
|
||||||
|
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
const done = sessions.finishAssistant(meta.id, p.id, {
|
||||||
|
text: '回答',
|
||||||
|
error: '错'.repeat(L.errorText + 100)
|
||||||
|
});
|
||||||
|
assert.strictEqual(done.text, '回答');
|
||||||
|
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
|
||||||
|
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
|
||||||
|
assert.strictEqual(renamed.title.length, L.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超过 20000 字的 assistant 正文经 append、finish 与磁盘恢复后保持完整', () => {
|
||||||
|
const { sessions, dir } = fresh('long-assistant');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: '请生成长回答' });
|
||||||
|
const initial = `占位开头${'初'.repeat(21000)}占位结尾`;
|
||||||
|
const placeholder = sessions.appendAssistant(meta.id, { text: initial });
|
||||||
|
assert.strictEqual(placeholder.text, initial, 'appendAssistant 不应静默截断 assistant 正文');
|
||||||
|
const answer = `回答开头${'答'.repeat(25000)}回答结尾`;
|
||||||
|
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: answer });
|
||||||
|
assert.strictEqual(done.text, answer, 'finishAssistant 不应静默截断 assistant 正文');
|
||||||
|
const raw = JSON.parse(fs.readFileSync(sessionFile(dir, meta.id), 'utf8'));
|
||||||
|
assert.strictEqual(raw.messages[1].text, answer, '超过 20000 字的回答必须完整落盘');
|
||||||
|
const restored = at(dir).sessions.messages(meta.id);
|
||||||
|
assert.strictEqual(restored.messages[1].text, answer, '超过 20000 字的回答必须从磁盘完整读回');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
|
||||||
|
const { sessions } = fresh('title');
|
||||||
|
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
|
||||||
|
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
|
||||||
|
const auto = sessions.create({});
|
||||||
|
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
|
||||||
|
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
|
||||||
|
const { sessions } = fresh('shape');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', contextRef: { scope: 'whatever' }
|
||||||
|
}), /会话上下文范围无效/);
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
|
||||||
|
const withContext = sessions.appendUser(meta.id, {
|
||||||
|
text: 'q',
|
||||||
|
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
|
||||||
|
});
|
||||||
|
assert.strictEqual(withContext.contextRef.chars, 4);
|
||||||
|
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
|
||||||
|
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
|
||||||
|
assert.strictEqual(withContext.contextRef.clipped, true);
|
||||||
|
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
|
||||||
|
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
|
||||||
|
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
|
||||||
|
}), /定位信息/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 上限策略 ---
|
||||||
|
|
||||||
|
test('会话数达上限抛中文错误,且不删除已有会话', () => {
|
||||||
|
const { sessions, dir } = fresh('total');
|
||||||
|
const total = sessions.LIMITS.sessionsTotal;
|
||||||
|
const ids = [];
|
||||||
|
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
|
||||||
|
const before = fs.readdirSync(sessionDir(dir)).length;
|
||||||
|
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
|
||||||
|
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
|
||||||
|
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
|
||||||
|
assert.strictEqual(sessions.list().length, total);
|
||||||
|
sessions.remove(ids[0]);
|
||||||
|
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
|
||||||
|
const { sessions } = fresh('evict');
|
||||||
|
const max = sessions.LIMITS.messagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
|
||||||
|
const first = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
|
||||||
|
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
const read = sessions.messages(meta.id, { limit: max });
|
||||||
|
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
|
||||||
|
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
|
||||||
|
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
|
||||||
|
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
|
||||||
|
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
|
||||||
|
assert.strictEqual(
|
||||||
|
read.meta.messageCount + read.meta.droppedMessages,
|
||||||
|
(max + 1) * 2,
|
||||||
|
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
|
||||||
|
);
|
||||||
|
const roles = read.messages.map((m) => m.role);
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||||
|
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
|
||||||
|
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
|
||||||
|
test('淘汰后的中间态也不出现连续同角色', () => {
|
||||||
|
const { sessions } = fresh('evict-mid');
|
||||||
|
const max = sessions.LIMITS.messagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
|
||||||
|
sessions.appendUser(meta.id, { text: '新问题' });
|
||||||
|
const read = sessions.messages(meta.id, { limit: max });
|
||||||
|
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
|
||||||
|
const roles = read.messages.map((m) => m.role);
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||||
|
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
|
||||||
|
}
|
||||||
|
assert.strictEqual(roles[0], 'user');
|
||||||
|
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
|
||||||
|
assert.strictEqual(roles[roles.length - 1], 'user');
|
||||||
|
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||||||
|
assert.strictEqual(
|
||||||
|
out.messages.length,
|
||||||
|
read.messages.length,
|
||||||
|
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
|
||||||
|
const { sessions, images } = fresh('img-evict');
|
||||||
|
const max = sessions.LIMITS.imagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const ids = [];
|
||||||
|
for (let i = 0; i <= max; i++) {
|
||||||
|
const put = images.put(jpeg(i + 1), 'image/jpeg');
|
||||||
|
ids.push(put.imageId);
|
||||||
|
sessions.appendUser(meta.id, {
|
||||||
|
text: `第 ${i} 张`,
|
||||||
|
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
|
||||||
|
});
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: `答 ${i}` });
|
||||||
|
}
|
||||||
|
const read = sessions.messages(meta.id, { limit: 500 });
|
||||||
|
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
|
||||||
|
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
|
||||||
|
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
|
||||||
|
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
|
||||||
|
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 文件隔离与写放大 ---
|
||||||
|
|
||||||
|
test('追加消息只重写自己那个会话文件', () => {
|
||||||
|
const { sessions, dir } = fresh('isolate');
|
||||||
|
const a = sessions.create({ title: 'a' });
|
||||||
|
const b = sessions.create({ title: 'b' });
|
||||||
|
const bFile = sessionFile(dir, b.id);
|
||||||
|
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||||||
|
round(sessions, a.id, '问', '答');
|
||||||
|
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||||||
|
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
|
||||||
|
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
|
||||||
|
round(sessions, a.id, '问2', '答2');
|
||||||
|
});
|
||||||
|
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
|
||||||
|
const { sessions, dir } = fresh('writes');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const isSession = (t) => t === `${file}.tmp`;
|
||||||
|
const oneRound = countWrites(isSession, () => {
|
||||||
|
sessions.appendUser(meta.id, { text: '问' });
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
|
||||||
|
});
|
||||||
|
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
|
||||||
|
// 对照:两轮就是四次,证明计数器没失灵
|
||||||
|
const twoRounds = countWrites(isSession, () => {
|
||||||
|
round(sessions, meta.id, '问2', '答2');
|
||||||
|
round(sessions, meta.id, '问3', '答3');
|
||||||
|
});
|
||||||
|
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
|
||||||
|
round(sessions, meta.id, '问4', '答4');
|
||||||
|
});
|
||||||
|
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
|
||||||
|
const { sessions, dir } = fresh('cache');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '原始问题', '原始回答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
|
||||||
|
const fixed = new Date(1700000000000);
|
||||||
|
fs.utimesSync(file, fixed, fixed);
|
||||||
|
const stat = fs.statSync(file);
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
|
||||||
|
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
|
||||||
|
raw.messages[0].text = '篡改问题';
|
||||||
|
const rewritten = JSON.stringify(raw, null, 2);
|
||||||
|
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
|
||||||
|
fs.writeFileSync(file, rewritten);
|
||||||
|
fs.utimesSync(file, fixed, fixed);
|
||||||
|
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
|
||||||
|
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
|
||||||
|
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 损坏自愈 ---
|
||||||
|
|
||||||
|
test('主文件损坏时从 .bak 恢复', () => {
|
||||||
|
const { sessions, dir } = fresh('bak');
|
||||||
|
const meta = sessions.create({ entryId: 'e1' });
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
fs.copyFileSync(file, `${file}.bak`);
|
||||||
|
fs.writeFileSync(file, '{ 这不是 JSON');
|
||||||
|
const read = sessions.messages(meta.id);
|
||||||
|
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
|
||||||
|
assert.strictEqual(read.messages[0].text, '问');
|
||||||
|
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||||||
|
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
|
||||||
|
const { sessions, dir } = fresh('corrupt');
|
||||||
|
const meta = sessions.create({ entryId: 'e1', title: '标题' });
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
fs.writeFileSync(file, 'broken');
|
||||||
|
fs.writeFileSync(`${file}.bak`, 'also broken');
|
||||||
|
const read = sessions.messages(meta.id);
|
||||||
|
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
|
||||||
|
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
|
||||||
|
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||||||
|
assert.strictEqual(corrupt.length, 1);
|
||||||
|
round(sessions, meta.id, '新问', '新答');
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index.json 丢失后扫目录重建,list 结果正确', () => {
|
||||||
|
const { sessions, dir } = fresh('reindex');
|
||||||
|
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||||||
|
const b = sessions.create({ entryId: 'e2', title: '乙' });
|
||||||
|
round(sessions, a.id, '问', '答');
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
fs.unlinkSync(idxFile);
|
||||||
|
const reloaded = at(dir).sessions;
|
||||||
|
const rows = reloaded.list();
|
||||||
|
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
|
||||||
|
const rowA = rows.find((r) => r.id === a.id);
|
||||||
|
assert.strictEqual(rowA.title, '甲');
|
||||||
|
assert.strictEqual(rowA.entryId, 'e1');
|
||||||
|
assert.strictEqual(rowA.messageCount, 2);
|
||||||
|
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
|
||||||
|
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
|
||||||
|
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
|
||||||
|
const { sessions, dir } = fresh('reindex2');
|
||||||
|
const a = sessions.create({ title: '甲' });
|
||||||
|
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
|
||||||
|
const reloaded = at(dir).sessions;
|
||||||
|
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
|
||||||
|
const { sessions, dir } = fresh('reindex3');
|
||||||
|
const a = sessions.create({ title: '甲' });
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||||||
|
idx.sessions.push({ id: '../escape', title: '恶意' });
|
||||||
|
idx.sessions.push({ id: '__proto__', title: '污染' });
|
||||||
|
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||||||
|
const rows = at(dir).sessions.list();
|
||||||
|
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
|
||||||
|
const { sessions, dir } = fresh('bad-msg');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '好问题', '好回答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
|
||||||
|
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||||
|
const read = at(dir).sessions.messages(meta.id);
|
||||||
|
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
|
||||||
|
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
|
||||||
|
assert.deepStrictEqual(read.messages[2].images, []);
|
||||||
|
assert.strictEqual(read.messages[2].task, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ID 校验 ---
|
||||||
|
|
||||||
|
test('非法会话 ID 被拒,不产生任何文件', () => {
|
||||||
|
const { sessions, dir } = fresh('ids');
|
||||||
|
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
|
||||||
|
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
|
||||||
|
for (const id of bad) {
|
||||||
|
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
|
||||||
|
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
|
||||||
|
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
|
||||||
|
}
|
||||||
|
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
|
||||||
|
'被拒的 ID 不应在磁盘上留下文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法 entryId 与 messageId 被拒', () => {
|
||||||
|
const { sessions } = fresh('ids2');
|
||||||
|
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
|
||||||
|
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
|
||||||
|
}
|
||||||
|
const meta = sessions.create({ entryId: 'e1' });
|
||||||
|
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||||||
|
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
|
||||||
|
}
|
||||||
|
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||||||
|
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
|
||||||
|
}
|
||||||
|
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finishAssistant 不能跨会话认领占位消息', () => {
|
||||||
|
const { sessions } = fresh('cross');
|
||||||
|
const a = sessions.create({});
|
||||||
|
const b = sessions.create({});
|
||||||
|
sessions.appendUser(a.id, { text: 'q' });
|
||||||
|
const p = sessions.appendAssistant(a.id, {});
|
||||||
|
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
|
||||||
|
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- historyFor ---
|
||||||
|
|
||||||
|
function history(sessions, id, budget) {
|
||||||
|
return sessions.historyFor(id, budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('historyFor 始终 pin 首条 user 消息', () => {
|
||||||
|
const { sessions } = fresh('hist-pin');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
|
||||||
|
for (let i = 0; i < 20; i++) round(sessions, meta.id, `问${i}`.repeat(20), `答${i}`.repeat(20));
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
|
||||||
|
assert.strictEqual(out.messages[0].role, 'user');
|
||||||
|
assert.ok(out.messages[0].text.includes('正文'),
|
||||||
|
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
|
||||||
|
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
|
||||||
|
assert.ok(out.dropped > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
|
||||||
|
const { sessions } = fresh('hist-mark');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
|
||||||
|
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
|
||||||
|
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
|
||||||
|
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
|
||||||
|
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
|
||||||
|
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
|
||||||
|
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
|
||||||
|
'不允许为了放标记而造出 system 之类的新角色');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 总字符不超过 maxChars', () => {
|
||||||
|
const { sessions } = fresh('hist-total');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < 15; i++) round(sessions, meta.id, `问${i}`.repeat(50), `答${i}`.repeat(50));
|
||||||
|
for (const maxChars of [200, 500, 1200, 3000]) {
|
||||||
|
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
|
||||||
|
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
|
||||||
|
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 单条超限中间挖空且保留尾部', () => {
|
||||||
|
const { sessions } = fresh('hist-clip');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const head = '开头标记';
|
||||||
|
const tail = '结尾标记';
|
||||||
|
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
|
||||||
|
const p = sessions.appendAssistant(meta.id, {});
|
||||||
|
sessions.finishAssistant(meta.id, p.id, { text: '答' });
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
|
||||||
|
const first = out.messages[0];
|
||||||
|
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
|
||||||
|
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
|
||||||
|
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
|
||||||
|
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
|
||||||
|
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
|
||||||
|
const { sessions, dir } = fresh('hist-lead');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
raw.messages = [
|
||||||
|
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
|
||||||
|
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
|
||||||
|
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
|
||||||
|
];
|
||||||
|
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||||
|
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
|
||||||
|
assert.strictEqual(out.messages[0].role, 'user',
|
||||||
|
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
|
||||||
|
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 不产生连续同角色消息', () => {
|
||||||
|
const { sessions, dir } = fresh('hist-roles');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
round(sessions, meta.id, '问', '答');
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
raw.messages = [
|
||||||
|
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
|
||||||
|
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
|
||||||
|
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
|
||||||
|
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
|
||||||
|
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
|
||||||
|
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
|
||||||
|
];
|
||||||
|
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||||
|
const reloaded = at(dir).sessions;
|
||||||
|
for (const budget of [
|
||||||
|
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
|
||||||
|
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
|
||||||
|
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
|
||||||
|
]) {
|
||||||
|
const out = reloaded.historyFor(meta.id, budget);
|
||||||
|
const roles = out.messages.map((m) => m.role);
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||||
|
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
|
||||||
|
}
|
||||||
|
if (roles.length) assert.strictEqual(roles[0], 'user');
|
||||||
|
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
|
||||||
|
const { sessions } = fresh('hist-dropped');
|
||||||
|
const max = sessions.LIMITS.messagesPerSession;
|
||||||
|
const meta = sessions.create({});
|
||||||
|
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||||
|
const read = sessions.messages(meta.id, { limit: max });
|
||||||
|
assert.ok(read.meta.droppedMessages > 0);
|
||||||
|
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||||||
|
assert.ok(out.dropped >= read.meta.droppedMessages,
|
||||||
|
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
|
||||||
|
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historyFor 在空会话与单条会话上不炸', () => {
|
||||||
|
const { sessions } = fresh('hist-edge');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
|
||||||
|
sessions.appendUser(meta.id, { text: '只有一条' });
|
||||||
|
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
|
||||||
|
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
|
||||||
|
assert.strictEqual(out.messages[0].role, 'user');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- imageIds 与 GC ---
|
||||||
|
|
||||||
|
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
|
||||||
|
const { sessions, images, dir } = fresh('gc-scan');
|
||||||
|
const a = sessions.create({ title: '甲' });
|
||||||
|
const b = sessions.create({ title: '乙' });
|
||||||
|
const imgA = images.put(jpeg(11), 'image/jpeg');
|
||||||
|
const imgB = images.put(jpeg(22), 'image/jpeg');
|
||||||
|
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
|
||||||
|
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
|
||||||
|
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
|
||||||
|
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||||
|
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||||||
|
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
|
||||||
|
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||||||
|
const reloaded = at(dir);
|
||||||
|
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
|
||||||
|
const ids = reloaded.sessions.imageIds();
|
||||||
|
assert.ok(ids.includes(imgB.imageId),
|
||||||
|
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
|
||||||
|
assert.ok(ids.includes(imgA.imageId));
|
||||||
|
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
|
||||||
|
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
|
||||||
|
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
|
||||||
|
const { sessions, images, dir } = fresh('gc-bak');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const img = images.put(jpeg(33), 'image/jpeg');
|
||||||
|
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
|
||||||
|
const file = sessionFile(dir, meta.id);
|
||||||
|
fs.copyFileSync(file, `${file}.bak`);
|
||||||
|
fs.writeFileSync(file, 'broken');
|
||||||
|
const reloaded = at(dir);
|
||||||
|
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
|
||||||
|
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
|
||||||
|
const pendingImg = images.put(jpeg(44), 'image/jpeg');
|
||||||
|
const s2 = fresh('gc-pending');
|
||||||
|
const m2 = s2.sessions.create({});
|
||||||
|
s2.sessions.appendUser(m2.id, { text: 'q' });
|
||||||
|
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
|
||||||
|
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
|
||||||
|
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话删除后其图像被 cleanup 回收', () => {
|
||||||
|
const { sessions, images, dir } = fresh('gc-remove');
|
||||||
|
const keep = sessions.create({ title: '留' });
|
||||||
|
const drop = sessions.create({ title: '删' });
|
||||||
|
const imgKeep = images.put(jpeg(55), 'image/jpeg');
|
||||||
|
const imgDrop = images.put(jpeg(66), 'image/jpeg');
|
||||||
|
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
|
||||||
|
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
|
||||||
|
sessions.remove(drop.id);
|
||||||
|
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
|
||||||
|
assert.strictEqual(removed, 1);
|
||||||
|
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
|
||||||
|
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ai-images ---
|
||||||
|
|
||||||
|
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
|
||||||
|
const { images } = fresh('img-mime');
|
||||||
|
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||||
|
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
|
||||||
|
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
|
||||||
|
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
|
||||||
|
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
|
||||||
|
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
|
||||||
|
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
|
||||||
|
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
|
||||||
|
const { images, dir } = fresh('img-dedup');
|
||||||
|
const bytes = jpeg(77, 1024);
|
||||||
|
const first = images.put(bytes, 'image/jpeg');
|
||||||
|
const second = images.put(Buffer.from(bytes), 'image/jpeg');
|
||||||
|
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
|
||||||
|
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
|
||||||
|
assert.strictEqual(images.totalBytes(), 1024);
|
||||||
|
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
|
||||||
|
const other = images.put(jpeg(78, 1024), 'image/jpeg');
|
||||||
|
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
|
||||||
|
assert.strictEqual(images.totalBytes(), 2048);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
|
||||||
|
const { images } = fresh('img-read');
|
||||||
|
const bytes = jpeg(88, 256);
|
||||||
|
const { imageId } = images.put(bytes, 'image/jpeg');
|
||||||
|
assert.ok(images.read(imageId).equals(bytes));
|
||||||
|
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
|
||||||
|
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
|
||||||
|
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
|
||||||
|
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
|
||||||
|
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
|
||||||
|
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
|
||||||
|
}
|
||||||
|
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
|
||||||
|
const { images, dir } = fresh('img-cleanup');
|
||||||
|
const a = images.put(jpeg(1, 128), 'image/jpeg');
|
||||||
|
const b = images.put(jpeg(2, 128), 'image/jpeg');
|
||||||
|
const c = images.put(jpeg(3, 128), 'image/jpeg');
|
||||||
|
assert.strictEqual(images.cleanup([a.imageId]), 0,
|
||||||
|
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
|
||||||
|
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
|
||||||
|
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
|
||||||
|
assert.ok(images.read(a.imageId));
|
||||||
|
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
|
||||||
|
assert.strictEqual(images.totalBytes(), 0);
|
||||||
|
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
|
||||||
|
// 白名单里的垃圾值不应意外保住任何文件
|
||||||
|
images.put(jpeg(4, 128), 'image/jpeg');
|
||||||
|
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
|
||||||
|
const { images } = fresh('img-empty');
|
||||||
|
assert.strictEqual(images.totalBytes(), 0);
|
||||||
|
assert.strictEqual(images.cleanup([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话里的图像元数据被规范化,非法引用被拒', () => {
|
||||||
|
const { sessions, images } = fresh('img-meta');
|
||||||
|
const meta = sessions.create({});
|
||||||
|
const img = images.put(jpeg(99, 512), 'image/jpeg');
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', images: [{ imageId: '../escape' }]
|
||||||
|
}), /会话图像标识无效/);
|
||||||
|
assert.throws(() => sessions.appendUser(meta.id, {
|
||||||
|
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
|
||||||
|
}), /仅支持 JPEG/);
|
||||||
|
const stored = sessions.appendUser(meta.id, {
|
||||||
|
text: 'q',
|
||||||
|
images: [
|
||||||
|
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
|
||||||
|
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
|
||||||
|
assert.deepStrictEqual(stored.images[0], {
|
||||||
|
imageId: img.imageId,
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
width: 100,
|
||||||
|
height: 200,
|
||||||
|
bytes: 512,
|
||||||
|
ocrIncluded: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 对账 ---
|
||||||
|
|
||||||
|
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
|
||||||
|
const { sessions } = fresh('orphan');
|
||||||
|
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
|
||||||
|
const gone = sessions.create({ entryId: 'e404', title: '已删' });
|
||||||
|
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||||||
|
round(sessions, gone.id, '问', '答');
|
||||||
|
const report = sessions.orphanReport(['e1']);
|
||||||
|
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
|
||||||
|
assert.strictEqual(report[0].entryId, 'e404');
|
||||||
|
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
|
||||||
|
assert.strictEqual(report[0].messageCount, 2);
|
||||||
|
assert.ok(report[0].bytes > 0);
|
||||||
|
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
|
||||||
|
assert.ok(!report.some((r) => r.sessionId === inLib.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
|
||||||
|
const { sessions } = fresh('forget');
|
||||||
|
const keep = sessions.create({ entryId: 'e1', title: '在库' });
|
||||||
|
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
|
||||||
|
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
|
||||||
|
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||||||
|
assert.strictEqual(sessions.forgetMany([]), 0);
|
||||||
|
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
|
||||||
|
const remaining = sessions.list().map((row) => row.id).sort();
|
||||||
|
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
|
||||||
|
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
|
||||||
|
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
|
||||||
|
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rebuildIndex 返回索引形状并与 list 一致', () => {
|
||||||
|
const { sessions } = fresh('rebuild');
|
||||||
|
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||||||
|
round(sessions, a.id, '问', '答');
|
||||||
|
const idx = sessions.rebuildIndex();
|
||||||
|
assert.strictEqual(idx.version, 1);
|
||||||
|
assert.strictEqual(idx.sessions.length, 1);
|
||||||
|
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
|
||||||
|
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
|
||||||
|
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list 返回的是副本,改动不会污染内存索引', () => {
|
||||||
|
const { sessions } = fresh('clone');
|
||||||
|
const meta = sessions.create({ title: '原标题' });
|
||||||
|
const rows = sessions.list();
|
||||||
|
rows[0].title = '被改坏';
|
||||||
|
assert.strictEqual(sessions.list()[0].title, '原标题');
|
||||||
|
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
|
||||||
|
});
|
||||||
@@ -326,7 +326,7 @@ test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
|
|||||||
assert.doesNotMatch(body.messages[1].content, /data:image/);
|
assert.doesNotMatch(body.messages[1].content, /data:image/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('超长上下文被截断且保留首尾', () => {
|
test('显式调用 clipContext 时中间挖空并保留首尾', () => {
|
||||||
const ai = setup();
|
const ai = setup();
|
||||||
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
|
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
|
||||||
const clipped = ai.clipContext(long, 2000);
|
const clipped = ai.clipContext(long, 2000);
|
||||||
@@ -336,6 +336,49 @@ test('超长上下文被截断且保留首尾', () => {
|
|||||||
assert.ok(clipped.includes('省略'), '未标注截断');
|
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('不支持的任务类型被拒绝', () => {
|
test('不支持的任务类型被拒绝', () => {
|
||||||
const ai = setup();
|
const ai = setup();
|
||||||
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
|
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
|
||||||
@@ -359,3 +402,297 @@ test('取消请求时抛出 AbortError 而不是静默返回', async () => {
|
|||||||
(e) => e.name === 'AbortError'
|
(e) => e.name === 'AbortError'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 多轮对话历史
|
||||||
|
const PROTOCOL_STREAMS = {
|
||||||
|
'chat-completions': () => streamResponse(sseBody(['答'])),
|
||||||
|
anthropic: () => streamResponse(
|
||||||
|
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '答' } })}\n\n`
|
||||||
|
+ `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`
|
||||||
|
),
|
||||||
|
'openai-responses': () => streamResponse(
|
||||||
|
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: '答' })}\n\n`
|
||||||
|
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
async function captureBody(protocol, args, options) {
|
||||||
|
const ai = setup({ protocol, ...options });
|
||||||
|
let body = null;
|
||||||
|
h.setHandler((_url, opts) => {
|
||||||
|
body = JSON.parse(opts.body);
|
||||||
|
return PROTOCOL_STREAMS[protocol]();
|
||||||
|
});
|
||||||
|
await ai.stream(args);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
|
||||||
|
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
|
||||||
|
// 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染
|
||||||
|
const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题';
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
|
||||||
|
if (protocol === 'chat-completions') {
|
||||||
|
assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user');
|
||||||
|
assert.strictEqual(base.messages[1].content, expectedUser);
|
||||||
|
} else if (protocol === 'anthropic') {
|
||||||
|
assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user');
|
||||||
|
assert.strictEqual(base.messages[0].content, expectedUser);
|
||||||
|
} else {
|
||||||
|
assert.strictEqual(base.input.length, 1, '单轮只该有一条 user');
|
||||||
|
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]);
|
||||||
|
}
|
||||||
|
for (const history of [undefined, null, [], 'not-an-array', {}]) {
|
||||||
|
const withArg = await captureBody(
|
||||||
|
protocol,
|
||||||
|
{ task: 'ask', text: '正文', question: '问题', history }
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
JSON.stringify(withArg),
|
||||||
|
JSON.stringify(base),
|
||||||
|
`${protocol} 的空 history 改变了请求体(history=${JSON.stringify(history)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chat-completions 把历史插在 system 之后、当前轮之前', async () => {
|
||||||
|
const body = await captureBody('chat-completions', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '第三个问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '第一个问题' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '第一个回答' },
|
||||||
|
{ id: 'c', role: 'user', text: '第二个问题' },
|
||||||
|
{ id: 'd', role: 'assistant', text: '第二个回答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
body.messages.map((m) => m.role),
|
||||||
|
['system', 'user', 'assistant', 'user', 'assistant', 'user'],
|
||||||
|
'历史必须在 messages 里,且当前轮排最后'
|
||||||
|
);
|
||||||
|
assert.strictEqual(body.messages[1].content, '第一个问题');
|
||||||
|
assert.strictEqual(body.messages[2].content, '第一个回答');
|
||||||
|
assert.strictEqual(body.messages[3].content, '第二个问题');
|
||||||
|
assert.strictEqual(body.messages[4].content, '第二个回答');
|
||||||
|
assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失');
|
||||||
|
assert.match(body.messages[5].content, /正文/, '当前轮正文丢失');
|
||||||
|
assert.ok(
|
||||||
|
!/第一个问题/.test(body.messages[0].content),
|
||||||
|
'历史不该被塞进 system,模型会把它当成指令'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anthropic 历史进 messages,system 仍在顶层', async () => {
|
||||||
|
const body = await captureBody('anthropic', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '新问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '旧问题' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.strictEqual(typeof body.system, 'string');
|
||||||
|
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
|
||||||
|
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||||
|
assert.strictEqual(body.messages[0].content, '旧问题');
|
||||||
|
assert.strictEqual(body.messages[1].content, '旧回答');
|
||||||
|
assert.match(body.messages[2].content, /新问题/);
|
||||||
|
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openai-responses 历史进 input,instructions 与 store:false 保持', async () => {
|
||||||
|
const body = await captureBody('openai-responses', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '新问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '旧问题' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.strictEqual(typeof body.instructions, 'string');
|
||||||
|
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
|
||||||
|
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
|
||||||
|
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
|
||||||
|
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||||
|
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
|
||||||
|
assert.strictEqual(body.input[0].content, '旧问题');
|
||||||
|
assert.strictEqual(body.input[1].content, '旧回答');
|
||||||
|
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
|
||||||
|
assert.strictEqual(body.input[2].content[0].type, 'input_text');
|
||||||
|
assert.match(body.input[2].content[0].text, /新问题/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史里的图像不被重发,只发当前轮的图', async () => {
|
||||||
|
const image = visualContext().image;
|
||||||
|
const history = [
|
||||||
|
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||||
|
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||||
|
];
|
||||||
|
const args = {
|
||||||
|
task: 'ask',
|
||||||
|
text: '',
|
||||||
|
question: '这张呢',
|
||||||
|
visualContexts: [visualContext()],
|
||||||
|
history
|
||||||
|
};
|
||||||
|
|
||||||
|
const chat = await captureBody('chat-completions', args, { vision: true });
|
||||||
|
const chatImages = chat.messages.flatMap(
|
||||||
|
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
|
||||||
|
);
|
||||||
|
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
|
||||||
|
|
||||||
|
const anthropic = await captureBody('anthropic', args, { vision: true });
|
||||||
|
const anthropicImages = anthropic.messages.flatMap(
|
||||||
|
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
|
||||||
|
);
|
||||||
|
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
|
||||||
|
|
||||||
|
const responses = await captureBody('openai-responses', args, { vision: true });
|
||||||
|
const responseImages = responses.input.flatMap(
|
||||||
|
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
|
||||||
|
);
|
||||||
|
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
|
||||||
|
|
||||||
|
const historyJson = JSON.stringify(history);
|
||||||
|
assert.strictEqual(historyJson, JSON.stringify([
|
||||||
|
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||||
|
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||||
|
]), '不该原地改写调用方传进来的历史数组');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
|
||||||
|
const body = await captureBody('anthropic', {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '新问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
|
||||||
|
{ id: 'b', role: 'user', text: '真正的第一问' },
|
||||||
|
{ id: 'c', role: 'assistant', text: '第一答' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
|
||||||
|
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
|
||||||
|
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||||
|
assert.strictEqual(body.messages[0].content, '真正的第一问');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '当前问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '上一问' },
|
||||||
|
{ id: 'b', role: 'assistant', text: '上一答' },
|
||||||
|
{ id: 'c', role: 'user', text: '没等到回答的追问' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
|
||||||
|
}
|
||||||
|
const flat = JSON.stringify(items);
|
||||||
|
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
|
||||||
|
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史内部相邻同角色被合并而不是丢弃', async () => {
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '当前问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '连问一' },
|
||||||
|
{ id: 'b', role: 'user', text: '连问二' },
|
||||||
|
{ id: 'c', role: 'assistant', text: '连答一' },
|
||||||
|
{ id: 'd', role: 'assistant', text: '连答二' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
roles,
|
||||||
|
['user', 'assistant', 'user'],
|
||||||
|
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
|
||||||
|
);
|
||||||
|
const flat = JSON.stringify(items);
|
||||||
|
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
|
||||||
|
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史含空文本时不产生空 content', async () => {
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '当前问题',
|
||||||
|
history: [
|
||||||
|
{ id: 'a', role: 'user', text: '有效提问' },
|
||||||
|
{ id: 'b', role: 'assistant', text: ' ' },
|
||||||
|
{ id: 'c', role: 'assistant', text: '' },
|
||||||
|
{ id: 'd', role: 'user', text: null },
|
||||||
|
{ id: 'e', role: 'assistant', text: '有效回答' },
|
||||||
|
null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
for (const item of items) {
|
||||||
|
const text = typeof item.content === 'string'
|
||||||
|
? item.content
|
||||||
|
: JSON.stringify(item.content);
|
||||||
|
assert.ok(text && text.trim(), `${protocol} 出现空 content,Anthropic 不接受空字符串`);
|
||||||
|
}
|
||||||
|
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||||
|
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史按旧到新排列,当前轮在最后', async () => {
|
||||||
|
const history = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
|
||||||
|
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
|
||||||
|
}
|
||||||
|
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||||
|
const body = await captureBody(protocol, {
|
||||||
|
task: 'ask',
|
||||||
|
text: '正文',
|
||||||
|
question: '问题4',
|
||||||
|
history
|
||||||
|
});
|
||||||
|
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||||
|
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
|
||||||
|
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
|
||||||
|
.map((mark) => flat.findIndex((s) => s.includes(mark)));
|
||||||
|
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
|
||||||
|
for (let i = 1; i < order.length; i++) {
|
||||||
|
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
|
||||||
|
}
|
||||||
|
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMessages 也接受历史参数', () => {
|
||||||
|
const ai = setup();
|
||||||
|
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
|
||||||
|
{ role: 'user', text: '旧问题' },
|
||||||
|
{ role: 'assistant', text: '旧回答' }
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
|
||||||
|
assert.strictEqual(msgs[1].content, '旧问题');
|
||||||
|
assert.match(msgs[3].content, /新问题/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -151,6 +151,94 @@ test('主文件损坏时优先从原子写入备份恢复', () => {
|
|||||||
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
|
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('批注计数统计对象总数,随内容变化失效', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const k = key('a.pdf');
|
||||||
|
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }, { type: 'Path' }] });
|
||||||
|
store.setPage('book', k, 4, { objects: [{ type: 'IText' }] });
|
||||||
|
// 是对象总数而非批注页数
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 3 });
|
||||||
|
|
||||||
|
// mtime + size 缓存必须在内容变化后失效
|
||||||
|
store.setPage('book', k, 4, { objects: [] });
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
store.setPage('book', k, 1, { objects: [] });
|
||||||
|
assert.deepStrictEqual(store.getCounts(), {});
|
||||||
|
|
||||||
|
// 同一条目的不同文档版本累加
|
||||||
|
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
|
||||||
|
// 损坏文件计 0 而不是抛错,否则整个书库都拿不到计数
|
||||||
|
fs.writeFileSync(path.join(dir, 'reader-annotations', 'broken.json'), '{ bad');
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
|
||||||
|
store.forget('book');
|
||||||
|
assert.deepStrictEqual(store.getCounts(), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('删除后重建同名批注文件不会命中旧缓存', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const folder = path.join(dir, 'reader-annotations');
|
||||||
|
const file = path.join(folder, 'book.json');
|
||||||
|
const k = key('a.pdf');
|
||||||
|
store.setPage('book', k, 1, { objects: [{ t: 'aa' }, { t: 'bb' }] });
|
||||||
|
// 固定到整毫秒,否则 utimesSync 无法精确复现原 mtime
|
||||||
|
const pinned = new Date(1700000000000);
|
||||||
|
fs.utimesSync(file, pinned, pinned);
|
||||||
|
const stale = fs.statSync(file);
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||||
|
|
||||||
|
// 构造与旧文件同尺寸、同 mtime 但内容不同的文件:
|
||||||
|
// 缓存键只有 mtime+size,forget 不清缓存就会返回过期的 2
|
||||||
|
store.forget('book');
|
||||||
|
const rebuilt = JSON.parse(JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
entryId: 'book',
|
||||||
|
documents: { [k]: { pages: { 1: { objects: [{ t: 'aa' }], updatedAt: 0 } }, updatedAt: 0 } }
|
||||||
|
}));
|
||||||
|
let text = JSON.stringify(rebuilt, null, 2);
|
||||||
|
assert.ok(text.length < stale.size, '重建内容应短于原文件才能补齐到同尺寸');
|
||||||
|
rebuilt.documents[k].pages['1'].objects[0].t = 'aa'.padEnd(2 + (stale.size - text.length), 'z');
|
||||||
|
text = JSON.stringify(rebuilt, null, 2);
|
||||||
|
assert.strictEqual(Buffer.byteLength(text, 'utf8'), stale.size, '需精确构造同尺寸文件');
|
||||||
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
fs.writeFileSync(file, text, 'utf8');
|
||||||
|
fs.utimesSync(file, pinned, pinned);
|
||||||
|
assert.strictEqual(fs.statSync(file).mtimeMs, stale.mtimeMs, '需精确复现同 mtime');
|
||||||
|
assert.strictEqual(fs.statSync(file).size, stale.size, '需精确复现同尺寸');
|
||||||
|
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { book: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('孤立批注按体积报告并可批量回收', () => {
|
||||||
|
const { store, dir } = fresh();
|
||||||
|
const k = key('a.pdf');
|
||||||
|
store.setPage('kept', k, 1, { objects: [{ t: 'Rect' }] });
|
||||||
|
store.setPage('gone_a', k, 1, { objects: [{ t: 'Rect' }, { t: 'Path' }] });
|
||||||
|
store.setPage('gone_b', k, 1, { objects: [{ t: 'IText' }] });
|
||||||
|
|
||||||
|
const orphans = store.orphanReport(['kept']);
|
||||||
|
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
|
||||||
|
assert.ok(orphans.every((o) => o.bytes > 0), '需报告体积供用户判断');
|
||||||
|
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').count, 2);
|
||||||
|
|
||||||
|
// 仍在书库的条目绝不能出现在回收清单里
|
||||||
|
assert.ok(!orphans.some((o) => o.entryId === 'kept'));
|
||||||
|
|
||||||
|
assert.strictEqual(store.forgetMany(orphans.map((o) => o.entryId)), 2);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'gone_a.json')), false);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'kept.json')), true);
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||||
|
assert.deepStrictEqual(store.orphanReport(['kept']), []);
|
||||||
|
|
||||||
|
// 传入非法 ID 不应中断其余回收
|
||||||
|
store.setPage('gone_c', k, 1, { objects: [{ t: 'Rect' }] });
|
||||||
|
assert.strictEqual(store.forgetMany(['../escape', 'gone_c']), 1);
|
||||||
|
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
test('forget 删除条目批注及备份残留', () => {
|
test('forget 删除条目批注及备份残留', () => {
|
||||||
const { store, dir } = fresh();
|
const { store, dir } = fresh();
|
||||||
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -75,8 +75,10 @@ app.whenReady().then(async () => {
|
|||||||
fs.mkdirSync(path.dirname(epubPath), { recursive: true });
|
fs.mkdirSync(path.dirname(epubPath), { recursive: true });
|
||||||
if (!fs.existsSync(epubPath)) {
|
if (!fs.existsSync(epubPath)) {
|
||||||
const { fetch: uf, ProxyAgent } = require('undici');
|
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', {
|
const r = await uf('https://www.gutenberg.org/ebooks/11.epub.noimages', {
|
||||||
dispatcher: new ProxyAgent({ uri: 'http://127.0.0.1:7890', connectTimeout: 30000 })
|
dispatcher: proxy ? new ProxyAgent({ uri: proxy, connectTimeout: 30000 }) : undefined
|
||||||
});
|
});
|
||||||
fs.writeFileSync(epubPath, Buffer.from(await r.arrayBuffer()));
|
fs.writeFileSync(epubPath, Buffer.from(await r.arrayBuffer()));
|
||||||
}
|
}
|
||||||
@@ -168,8 +170,8 @@ app.whenReady().then(async () => {
|
|||||||
"document.getElementById('aiConfirmScope').textContent+' '+document.getElementById('aiConfirmCost').textContent"
|
"document.getElementById('aiConfirmScope').textContent+' '+document.getElementById('aiConfirmCost').textContent"
|
||||||
));
|
));
|
||||||
chk('确认框包含范围、字数与 token 估算', /全文/.test(summary) && /字/.test(summary) && /tokens/.test(summary), summary);
|
chk('确认框包含范围、字数与 token 估算', /全文/.test(summary) && /字/.test(summary) && /tokens/.test(summary), summary);
|
||||||
chk('确认框明确警告全文可能超限',
|
chk('确认框说明全文完整发送且超限由接口报错',
|
||||||
/可能超过模型的上下文限制/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
|
/完整发送/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
|
||||||
chk('确认框使用应用按钮而非原生弹窗',
|
chk('确认框使用应用按钮而非原生弹窗',
|
||||||
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
|
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
|
||||||
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
|
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
|
||||||
@@ -193,7 +195,12 @@ app.whenReady().then(async () => {
|
|||||||
await new Promise((r) => setTimeout(r, 3820));
|
await new Promise((r) => setTimeout(r, 3820));
|
||||||
chk('用户同意后发送且仅一次', received.length === 1, '请求数=' + received.length);
|
chk('用户同意后发送且仅一次', received.length === 1, '请求数=' + received.length);
|
||||||
chk('外发内容为全文正文', charsOf(received[0]) > 1000, '字符=' + charsOf(received[0]));
|
chk('外发内容为全文正文', charsOf(received[0]) > 1000, '字符=' + charsOf(received[0]));
|
||||||
chk('超长全文按上限截断后才外发', charsOf(received[0]) <= 12000 + 2000, '字符=' + charsOf(received[0]));
|
// 界面告知的字数必须等于真正离开进程的字数。旧行为在这里砍掉 80% 正文却仍显示全文字数
|
||||||
|
chk('外发字数与界面告知一致,正文未被静默截断',
|
||||||
|
charsOf(received[0]) >= docChars,
|
||||||
|
`外发=${charsOf(received[0])} 界面告知=${docChars}`);
|
||||||
|
chk('外发正文不含本地截断标记',
|
||||||
|
!JSON.stringify(received[0]).includes('中间省略'));
|
||||||
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
|
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
|
||||||
const output = document.getElementById('aiOutput');
|
const output = document.getElementById('aiOutput');
|
||||||
return output.querySelector('h1')?.textContent === '回答'
|
return output.querySelector('h1')?.textContent === '回答'
|
||||||
@@ -245,7 +252,8 @@ app.whenReady().then(async () => {
|
|||||||
})()`));
|
})()`));
|
||||||
await js("document.getElementById('aiConfirmSendBtn').click()");
|
await js("document.getElementById('aiConfirmSendBtn').click()");
|
||||||
await new Promise((r) => setTimeout(r, 4000));
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
const pageContent = received[1] && received[1].messages && received[1].messages[1].content;
|
// 多轮之后本轮提问固定在末尾,历史占据中间位置,因此不能按固定下标取
|
||||||
|
const pageContent = received[1] && received[1].messages && received[1].messages.at(-1).content;
|
||||||
const pageImage = Array.isArray(pageContent)
|
const pageImage = Array.isArray(pageContent)
|
||||||
? pageContent.find((part) => part && part.type === 'image_url')
|
? pageContent.find((part) => part && part.type === 'image_url')
|
||||||
: null;
|
: null;
|
||||||
@@ -328,13 +336,152 @@ app.whenReady().then(async () => {
|
|||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
await js("document.getElementById('aiConfirmSendBtn').click()");
|
await js("document.getElementById('aiConfirmSendBtn').click()");
|
||||||
await new Promise((r) => setTimeout(r, 4000));
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
const regionContent = received[2] && received[2].messages && received[2].messages[1].content;
|
const regionContent = received[2] && received[2].messages && received[2].messages.at(-1).content;
|
||||||
const regionImage = Array.isArray(regionContent)
|
const regionImage = Array.isArray(regionContent)
|
||||||
? regionContent.find((part) => part && part.type === 'image_url')
|
? regionContent.find((part) => part && part.type === 'image_url')
|
||||||
: null;
|
: null;
|
||||||
chk('框选区域作为单张图像上下文发送', received.length === 3
|
chk('框选区域作为单张图像上下文发送', received.length === 3
|
||||||
&& /^data:image\/jpeg;base64,/.test(regionImage?.image_url?.url || ''));
|
&& /^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(`(() => {
|
chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
|
||||||
const output = document.getElementById('aiOutput');
|
const output = document.getElementById('aiOutput');
|
||||||
const text = 'x'.repeat(256 * 1024 + 1);
|
const text = 'x'.repeat(256 * 1024 + 1);
|
||||||
@@ -433,4 +580,8 @@ app.whenReady().then(async () => {
|
|||||||
console.log(`\n通过 ${results.length - bad}/${results.length}`);
|
console.log(`\n通过 ${results.length - bad}/${results.length}`);
|
||||||
server.close();
|
server.close();
|
||||||
app.exit(bad ? 1 : 0);
|
app.exit(bad ? 1 : 0);
|
||||||
}).catch((e) => { console.error('异常:', e); app.exit(1); });
|
}).catch((e) => {
|
||||||
|
console.error('异常:', e);
|
||||||
|
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ async function wait(ms) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 代理只在设了 HTTPS_PROXY 时用。写死本机代理会让没有代理的环境(CI)
|
||||||
|
// 直接 ECONNREFUSED,取不到夹具。
|
||||||
async function ensurePdf() {
|
async function ensurePdf() {
|
||||||
if (fs.existsSync(PDF_CACHE)) return;
|
if (fs.existsSync(PDF_CACHE)) return;
|
||||||
fs.mkdirSync(path.dirname(PDF_CACHE), { recursive: true });
|
fs.mkdirSync(path.dirname(PDF_CACHE), { recursive: true });
|
||||||
const { fetch, ProxyAgent } = require('undici');
|
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', {
|
const response = await fetch('https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', {
|
||||||
dispatcher: new ProxyAgent({ uri: 'http://127.0.0.1:7890', connectTimeout: 30000 })
|
dispatcher: proxy ? new ProxyAgent({ uri: proxy, connectTimeout: 30000 }) : undefined
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`PDF 下载失败:${response.status}`);
|
if (!response.ok) throw new Error(`PDF 下载失败:${response.status}`);
|
||||||
fs.writeFileSync(PDF_CACHE, Buffer.from(await response.arrayBuffer()));
|
fs.writeFileSync(PDF_CACHE, Buffer.from(await response.arrayBuffer()));
|
||||||
@@ -411,6 +414,46 @@ app.whenReady().then(async () => {
|
|||||||
check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
|
check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
|
||||||
check('重开窗口无渲染错误', second.errors.length === 0, second.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;
|
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
|
||||||
fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
|
fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,15 @@ const knownChunks = Array.from({ length: 6 }, (_unused, index) => Buffer.from(
|
|||||||
const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
|
const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
|
||||||
`unknown-chunk-${index}-` + String.fromCharCode(97 + index).repeat(12 * 1024)
|
`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 knownPayload = Buffer.concat(knownChunks);
|
||||||
const unknownPayload = Buffer.concat(unknownChunks);
|
const unknownPayload = Buffer.concat(unknownChunks);
|
||||||
|
const rangedPayload = Buffer.concat(rangedChunks);
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
const rangedRequests = [];
|
||||||
let server;
|
let server;
|
||||||
let testWindow;
|
let testWindow;
|
||||||
|
|
||||||
@@ -35,6 +40,15 @@ function wait(ms) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, 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) {
|
function serveChunks(response, chunks, contentLength) {
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
@@ -58,6 +72,46 @@ function serveChunks(response, chunks, contentLength) {
|
|||||||
sendNext();
|
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) {
|
function monotonic(events) {
|
||||||
return events.every((event, index) => {
|
return events.every((event, index) => {
|
||||||
const current = Number(event.receivedBytes);
|
const current = Number(event.receivedBytes);
|
||||||
@@ -139,6 +193,8 @@ async function run() {
|
|||||||
serveChunks(response, knownChunks, knownPayload.length);
|
serveChunks(response, knownChunks, knownPayload.length);
|
||||||
} else if (request.url === '/unknown.txt') {
|
} else if (request.url === '/unknown.txt') {
|
||||||
serveChunks(response, unknownChunks, null);
|
serveChunks(response, unknownChunks, null);
|
||||||
|
} else if (request.url.startsWith('/range.txt')) {
|
||||||
|
serveRange(request, response);
|
||||||
} else {
|
} else {
|
||||||
response.writeHead(404, { Connection: 'close' });
|
response.writeHead(404, { Connection: 'close' });
|
||||||
response.end('not found');
|
response.end('not found');
|
||||||
@@ -210,7 +266,12 @@ async function run() {
|
|||||||
})()`);
|
})()`);
|
||||||
|
|
||||||
check('preload 暴露下载 API',
|
check('preload 暴露下载 API',
|
||||||
await testWindow.webContents.executeJavaScript('typeof window.api.downloadFile === "function"'));
|
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 port = server.address().port;
|
||||||
const known = await downloadInRenderer(
|
const known = await downloadInRenderer(
|
||||||
@@ -294,6 +355,188 @@ async function run() {
|
|||||||
&& unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
|
&& unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
|
||||||
unknownEntry && unknownEntry.id);
|
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 css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'style.css'), 'utf8');
|
||||||
const downloadedRule = cssRule(css, '.dl-btn.downloaded');
|
const downloadedRule = cssRule(css, '.dl-btn.downloaded');
|
||||||
const backgroundValue = declaration(downloadedRule, 'background');
|
const backgroundValue = declaration(downloadedRule, 'background');
|
||||||
@@ -313,8 +556,8 @@ async function run() {
|
|||||||
await wait(100);
|
await wait(100);
|
||||||
const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
|
const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
|
||||||
check('下载流程没有渲染器错误',
|
check('下载流程没有渲染器错误',
|
||||||
rendererErrors.length === 0 && pageErrors.length === 0,
|
rendererErrors.length === 0 && directPageErrors.length === 0 && pageErrors.length === 0,
|
||||||
rendererErrors.concat(pageErrors).join(' | '));
|
rendererErrors.concat(directPageErrors, pageErrors).join(' | '));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
|
check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -59,17 +59,20 @@ async function js(source) {
|
|||||||
return win.webContents.executeJavaScript(source);
|
return win.webContents.executeJavaScript(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll(name, predicate, timeout = 8000) {
|
// soft=true 时超时不抛也不记失败,只返回 false,交给调用方自己断言,
|
||||||
|
// 这样失败信息里能带上真实量到的状态而不是一句"等待超时"
|
||||||
|
async function poll(name, predicate, timeout = 8000, soft = false) {
|
||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
if (await predicate()) return;
|
if (await predicate()) return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
await wait(50);
|
await wait(50);
|
||||||
}
|
}
|
||||||
|
if (soft) return false;
|
||||||
const detail = lastError ? lastError.message : '等待超时';
|
const detail = lastError ? lastError.message : '等待超时';
|
||||||
check(name, false, detail);
|
check(name, false, detail);
|
||||||
throw new Error(`${name}: ${detail}`);
|
throw new Error(`${name}: ${detail}`);
|
||||||
@@ -159,6 +162,7 @@ app.whenReady().then(async () => {
|
|||||||
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
||||||
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
|
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
|
||||||
const noteAssets = require(path.join(ROOT, 'src', 'reader', 'note-assets'));
|
const noteAssets = require(path.join(ROOT, 'src', 'reader', 'note-assets'));
|
||||||
|
const noteWindow = require(path.join(ROOT, 'src', 'reader', 'note-window'));
|
||||||
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
||||||
const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
|
const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
|
||||||
const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
|
const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
|
||||||
@@ -481,13 +485,28 @@ app.whenReady().then(async () => {
|
|||||||
)) || null;
|
)) || null;
|
||||||
return !!readerWindow;
|
return !!readerWindow;
|
||||||
});
|
});
|
||||||
await poll('封面打开的 PDF 在内置阅读器渲染', async () => (
|
// 这本书的文件顺序是 [txt, pdf],封面走的是"第一个可阅读文件",
|
||||||
|
// 也就是 txt(txt/md 同样能内置阅读,走 text-adapter 转 epub 渲染)。
|
||||||
|
// 这里断言"渲染出正文",不要写死 PDF 画布:那样等于把
|
||||||
|
// "txt 不可阅读所以退到 pdf" 这个旧缺陷当成期望行为锁死
|
||||||
|
await poll('封面打开的书在内置阅读器渲染出正文', async () => (
|
||||||
!readerWindow.isDestroyed()
|
!readerWindow.isDestroyed()
|
||||||
&& readerWindow.webContents.executeJavaScript(
|
&& readerWindow.webContents.executeJavaScript(`(() => {
|
||||||
"document.querySelector('.pdfx-page[data-page=\"1\"] .pdfx-canvas')?.width > 0"
|
if (document.querySelector('.doc-overlay.err')) return false;
|
||||||
)
|
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||||
), 15000);
|
if (canvas && canvas.width > 0) return true;
|
||||||
|
const frame = document.querySelector('.host-epub iframe');
|
||||||
|
const body = frame && frame.contentDocument && frame.contentDocument.body;
|
||||||
|
return !!(body && body.textContent.trim().length > 0);
|
||||||
|
})()`)
|
||||||
|
), 20000);
|
||||||
check('点击可阅读图书封面直接打开内置阅读器', !!readerWindow);
|
check('点击可阅读图书封面直接打开内置阅读器', !!readerWindow);
|
||||||
|
check(
|
||||||
|
'封面打开的是第一个可阅读文件(txt 也算)',
|
||||||
|
(await readerWindow.webContents.executeJavaScript(
|
||||||
|
"new URLSearchParams(location.search).get('fileIndex')"
|
||||||
|
)) === '0'
|
||||||
|
);
|
||||||
readerWindow.destroy();
|
readerWindow.destroy();
|
||||||
await wait(200);
|
await wait(200);
|
||||||
check(
|
check(
|
||||||
@@ -738,6 +757,143 @@ app.whenReady().then(async () => {
|
|||||||
&& (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
|
&& (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
|
||||||
|
await pollJs('多选前重置为全部书籍', "document.querySelectorAll('#libGrid .card').length === 3");
|
||||||
|
|
||||||
|
check(
|
||||||
|
'默认不进入多选模式',
|
||||||
|
await js(`document.getElementById('librarySelectionBar').classList.contains('hidden')
|
||||||
|
&& document.querySelectorAll('#libGrid .card-select').length === 0`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await js("document.getElementById('librarySelectModeBtn').click()");
|
||||||
|
await pollJs(
|
||||||
|
'进入多选模式后卡片出现复选框',
|
||||||
|
"document.querySelectorAll('#libGrid .card-select').length === 3"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'多选模式隐藏单卡操作并停用封面阅读入口',
|
||||||
|
await js(`!document.getElementById('librarySelectionBar').classList.contains('hidden')
|
||||||
|
&& document.querySelectorAll('#libGrid .lib-card-actions').length === 0
|
||||||
|
&& document.querySelectorAll('#libGrid .card-cover[data-act="read"]').length === 0
|
||||||
|
&& document.getElementById('libraryBulkOrganizeBtn').disabled
|
||||||
|
&& document.getElementById('libraryBulkRemoveBtn').disabled`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await js("document.querySelectorAll('#libGrid .card-select input')[0].click()");
|
||||||
|
await pollJs(
|
||||||
|
'勾选单项后启用批量操作',
|
||||||
|
`document.getElementById('librarySelectionCount').textContent.includes('已选择 1')
|
||||||
|
&& document.getElementById('librarySelectAll').indeterminate === true
|
||||||
|
&& !document.getElementById('libraryBulkOrganizeBtn').disabled`
|
||||||
|
);
|
||||||
|
|
||||||
|
await js("document.getElementById('librarySelectAll').click()");
|
||||||
|
await pollJs(
|
||||||
|
'全选覆盖当前筛选结果',
|
||||||
|
`document.getElementById('librarySelectionCount').textContent.includes('已选择 3')
|
||||||
|
&& document.getElementById('librarySelectAllLabel').textContent === '取消全选'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 全选只作用于当前筛选结果:切到只含一本的书架后,看不见的选中项必须失效,
|
||||||
|
// 否则批量操作会误伤用户看不到的书
|
||||||
|
await js(`(() => {
|
||||||
|
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||||
|
.find((candidate) => candidate.textContent.trim() === '研究书架');
|
||||||
|
button.click();
|
||||||
|
})()`);
|
||||||
|
await pollJs(
|
||||||
|
'切换筛选后丢弃不可见项的选中状态',
|
||||||
|
`document.querySelectorAll('#libGrid .card').length === 1
|
||||||
|
&& document.getElementById('librarySelectionCount').textContent.includes('已选择 1')`
|
||||||
|
);
|
||||||
|
|
||||||
|
await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
|
||||||
|
await pollJs('恢复全部书籍视图', "document.querySelectorAll('#libGrid .card').length === 3");
|
||||||
|
await js("document.getElementById('librarySelectAll').click()");
|
||||||
|
await pollJs(
|
||||||
|
'重新全选三本',
|
||||||
|
"document.getElementById('librarySelectionCount').textContent.includes('已选择 3')"
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforeBulk = library.list().reduce((acc, item) => {
|
||||||
|
acc[item.id] = { shelfId: item.shelfId, tags: (item.tags || []).slice() };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const total = Object.keys(beforeBulk).length;
|
||||||
|
const holdersOf = (name) => Object.values(beforeBulk)
|
||||||
|
.filter((entry) => entry.tags.some((tag) => tag.toLocaleLowerCase() === name.toLocaleLowerCase()))
|
||||||
|
.length;
|
||||||
|
const expectedState = (name) => {
|
||||||
|
const held = holdersOf(name);
|
||||||
|
return held === 0 ? 'none' : (held === total ? 'all' : 'some');
|
||||||
|
};
|
||||||
|
const tagStateExpectations = ['Shared', 'Methods', 'Review', 'Archive']
|
||||||
|
.map((name) => [name, expectedState(name), holdersOf(name)]);
|
||||||
|
|
||||||
|
await js("document.getElementById('libraryBulkOrganizeBtn').click()");
|
||||||
|
await waitForModal('批量整理 3 本');
|
||||||
|
const actualStates = await js(`(() => {
|
||||||
|
const map = {};
|
||||||
|
document.querySelectorAll('#libraryBulkTags input[type="checkbox"]').forEach((box) => {
|
||||||
|
map[box.value] = { state: box.dataset.state, indeterminate: box.indeterminate, checked: box.checked };
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
})()`);
|
||||||
|
check(
|
||||||
|
'批量整理按持有比例给出标签三态',
|
||||||
|
tagStateExpectations.every(([name, state]) => {
|
||||||
|
const actual = actualStates[name];
|
||||||
|
if (!actual || actual.state !== state) return false;
|
||||||
|
if (state === 'some') return actual.indeterminate === true && actual.checked === false;
|
||||||
|
if (state === 'all') return actual.indeterminate === false && actual.checked === true;
|
||||||
|
return actual.indeterminate === false && actual.checked === false;
|
||||||
|
})
|
||||||
|
// 三种状态都要真实出现过,否则这条断言可能什么都没验到
|
||||||
|
&& new Set(tagStateExpectations.map(([, state]) => state)).size === 3,
|
||||||
|
tagStateExpectations.map(([n, s, h]) => `${n}=${s}(${h}/${total})`).join(' ')
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'所选书籍书架不一致时默认保持不变',
|
||||||
|
await js("document.getElementById('libraryBulkShelf').value === '__keep__'")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 只勾选一个未被任何书持有的标签,其余保持部分选中
|
||||||
|
await js(`(() => {
|
||||||
|
const box = Array.from(document.querySelectorAll('#libraryBulkTags input[type="checkbox"]'))
|
||||||
|
.find((candidate) => candidate.value === 'Archive');
|
||||||
|
box.click();
|
||||||
|
})()`);
|
||||||
|
await submitModal();
|
||||||
|
await poll(
|
||||||
|
'批量整理写入真实存储',
|
||||||
|
() => Promise.resolve(library.list().every((item) => (item.tags || []).includes('Archive')))
|
||||||
|
);
|
||||||
|
const afterBulk = library.list().reduce((acc, item) => {
|
||||||
|
acc[item.id] = { shelfId: item.shelfId, tags: (item.tags || []).slice() };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
check(
|
||||||
|
'新增标签应用到全部选中项',
|
||||||
|
Object.values(afterBulk).every((entry) => entry.tags.includes('Archive'))
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'部分选中的标签保持原样,未被覆盖抹掉',
|
||||||
|
Object.entries(beforeBulk).every(([id, before]) => (
|
||||||
|
before.tags.every((tag) => afterBulk[id].tags.includes(tag))
|
||||||
|
)),
|
||||||
|
JSON.stringify(Object.values(afterBulk).map((e) => e.tags))
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'书架保持不变时未被改动',
|
||||||
|
Object.entries(beforeBulk).every(([id, before]) => afterBulk[id].shelfId === before.shelfId)
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'批量整理完成后退出多选模式',
|
||||||
|
await js(`document.getElementById('librarySelectionBar').classList.contains('hidden')
|
||||||
|
&& document.querySelectorAll('#libGrid .card-select').length === 0`)
|
||||||
|
);
|
||||||
|
|
||||||
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
|
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
|
||||||
await pollJs(
|
await pollJs(
|
||||||
'我的笔记页渲染完成',
|
'我的笔记页渲染完成',
|
||||||
@@ -1452,6 +1608,292 @@ app.whenReady().then(async () => {
|
|||||||
);
|
);
|
||||||
dialog.showOpenDialog = originalShowOpenDialog;
|
dialog.showOpenDialog = originalShowOpenDialog;
|
||||||
|
|
||||||
|
// --- 笔记独立窗口 ---
|
||||||
|
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
|
||||||
|
await pollJs('笔记页有可开窗的卡片', "document.querySelectorAll('#notesList .note-card').length > 0");
|
||||||
|
const windowNote = readerStore.listNotes({}).find((note) => note.associated !== false);
|
||||||
|
check('存在可用于开窗的笔记', !!windowNote);
|
||||||
|
|
||||||
|
// 只数笔记窗口。总窗口数会被阅读器窗口的开关干扰,
|
||||||
|
// 之前用总数当基线,阅读器中途关掉就把「没多开窗」误判成失败
|
||||||
|
const noteWindowCount = () => BrowserWindow.getAllWindows()
|
||||||
|
.filter((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'))
|
||||||
|
.length;
|
||||||
|
check('开窗前没有笔记窗口', noteWindowCount() === 0, `笔记窗口数=${noteWindowCount()}`);
|
||||||
|
// 按钮必须限定在目标笔记那张卡片内:笔记页此时有多张卡片,
|
||||||
|
// 全局找「编辑」会命中别的卡片,断言就变成了自欺欺人
|
||||||
|
const cardScript = (inner) => `(() => {
|
||||||
|
const target = document.querySelector('#notesList .note-card[data-note-id=' +
|
||||||
|
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||||
|
if (!target) throw new Error('找不到目标笔记卡片');
|
||||||
|
${inner}
|
||||||
|
})()`;
|
||||||
|
const cardLabels = () => js(cardScript(
|
||||||
|
'return [...target.querySelectorAll(".note-action")].map((b) => b.textContent).join(",");'
|
||||||
|
));
|
||||||
|
const clickCardAction = (label) => js(cardScript(`
|
||||||
|
const button = [...target.querySelectorAll(".note-action")]
|
||||||
|
.find((item) => item.textContent === ${JSON.stringify(label)});
|
||||||
|
if (!button) throw new Error("找不到按钮:" + ${JSON.stringify(label)});
|
||||||
|
button.click();
|
||||||
|
return true;
|
||||||
|
`));
|
||||||
|
|
||||||
|
await clickCardAction('独立窗口');
|
||||||
|
// 窗口创建与 URL 就位之间有间隔,刚建好时 getURL() 还是空串,必须轮询
|
||||||
|
const findNoteWindow = () => BrowserWindow.getAllWindows()
|
||||||
|
.find((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'));
|
||||||
|
await poll(
|
||||||
|
'点击独立窗口后真的多出一个笔记窗口',
|
||||||
|
async () => noteWindowCount() === 1 && !!findNoteWindow(),
|
||||||
|
15000
|
||||||
|
);
|
||||||
|
const noteWin = findNoteWindow();
|
||||||
|
check('新窗口加载的是笔记页面', !!noteWin);
|
||||||
|
const noteJs = (source) => noteWin.webContents.executeJavaScript(source);
|
||||||
|
// 多标签后表单是每个标签一份,查询必须限定在当前激活的那个视图内,
|
||||||
|
// 否则回收/切换过程中会量到别的标签
|
||||||
|
const activeScript = (inner) => `(() => {
|
||||||
|
const view = [...document.querySelectorAll('.note-tab-view')]
|
||||||
|
.find((item) => !item.classList.contains('inactive'));
|
||||||
|
if (!view) throw new Error('没有激活的笔记标签');
|
||||||
|
${inner}
|
||||||
|
})()`;
|
||||||
|
await poll(
|
||||||
|
'笔记窗口标签就绪',
|
||||||
|
() => noteJs(`(() => {
|
||||||
|
const view = [...document.querySelectorAll('.note-tab-view')]
|
||||||
|
.find((item) => !item.classList.contains('inactive'));
|
||||||
|
return !!(view && view.querySelector('.note-window-title'));
|
||||||
|
})()`),
|
||||||
|
15000
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'笔记窗口载入的是被点开的那一条',
|
||||||
|
(await noteJs(activeScript("return view.querySelector('.note-window-title').value;")))
|
||||||
|
=== String(windowNote.title || '')
|
||||||
|
&& (await noteJs("document.getElementById('noteWindowError').textContent")) === '',
|
||||||
|
await noteJs(activeScript("return view.querySelector('.note-window-title').value;"))
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'开出的是一个标签',
|
||||||
|
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||||
|
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同一条笔记不允许开出第二个标签,否则两个编辑器会整条覆盖对方
|
||||||
|
await clickCardAction('切到窗口');
|
||||||
|
await wait(1200);
|
||||||
|
check(
|
||||||
|
'同一条笔记再次开窗只聚焦不新增窗口',
|
||||||
|
noteWindowCount() === 1,
|
||||||
|
`笔记窗口数=${noteWindowCount()}`
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'同一条笔记再次开窗也不新增标签',
|
||||||
|
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||||
|
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 已开窗时该卡片的按钮改为切窗,避免模态与窗口同时编辑同一条
|
||||||
|
const openedLabels = await cardLabels();
|
||||||
|
check(
|
||||||
|
'已开窗后该笔记不再提供开模态的编辑按钮',
|
||||||
|
openedLabels.includes('在窗口中编辑') && !openedLabels.split(',').includes('编辑'),
|
||||||
|
openedLabels
|
||||||
|
);
|
||||||
|
await clickCardAction('在窗口中编辑');
|
||||||
|
await wait(1000);
|
||||||
|
check(
|
||||||
|
'点「在窗口中编辑」不会打开模态',
|
||||||
|
await js("document.getElementById('modal').classList.contains('hidden')")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 断言真正落盘的内容,而不是界面状态
|
||||||
|
const editedTitle = `窗口改名 ${Date.now()}`;
|
||||||
|
await noteJs(activeScript(`
|
||||||
|
const title = view.querySelector('.note-window-title');
|
||||||
|
title.value = ${JSON.stringify(editedTitle)};
|
||||||
|
title.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
const tags = view.querySelector('.note-window-tags-input');
|
||||||
|
tags.value = '窗口标签';
|
||||||
|
tags.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
`));
|
||||||
|
// 有未保存修改时标签上要有脏标记,否则关闭前的二次确认无从触发
|
||||||
|
await poll(
|
||||||
|
'未保存的修改在标签上有脏标记',
|
||||||
|
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 1"),
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
check('未保存的修改在标签上有脏标记', true);
|
||||||
|
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
|
||||||
|
await poll(
|
||||||
|
'笔记窗口的修改真正落盘',
|
||||||
|
async () => {
|
||||||
|
const stored = readerStore.listNotes({}).find((note) => note.id === windowNote.id);
|
||||||
|
return !!stored && stored.title === editedTitle && stored.tags.includes('窗口标签');
|
||||||
|
},
|
||||||
|
12000
|
||||||
|
);
|
||||||
|
check('笔记窗口保存后落盘内容正确', true);
|
||||||
|
|
||||||
|
await poll(
|
||||||
|
'笔记窗口的改动回流到主窗口列表',
|
||||||
|
() => js(`Array.from(document.querySelectorAll('#notesList .note-title'))
|
||||||
|
.some((node) => node.textContent.trim() === ${JSON.stringify(editedTitle)})`),
|
||||||
|
12000
|
||||||
|
);
|
||||||
|
check('主窗口列表随笔记窗口保存刷新', true);
|
||||||
|
await poll(
|
||||||
|
'保存后脏标记清除',
|
||||||
|
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 0"),
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
check('保存后脏标记清除', true);
|
||||||
|
|
||||||
|
const noteWinErrors = [];
|
||||||
|
noteWin.webContents.on('console-message', (event) => {
|
||||||
|
if (event.level >= 2) noteWinErrors.push(event.message.slice(0, 120));
|
||||||
|
});
|
||||||
|
await wait(200);
|
||||||
|
check('笔记窗口没有控制台错误', noteWinErrors.length === 0, noteWinErrors.slice(0, 2).join(' | '));
|
||||||
|
|
||||||
|
// 第二条笔记要进同一个窗口的新标签,而不是再开一个窗口
|
||||||
|
const secondNote = readerStore.listNotes({})
|
||||||
|
.find((note) => note.id !== windowNote.id && note.associated !== false);
|
||||||
|
if (secondNote) {
|
||||||
|
await js(`(() => {
|
||||||
|
const target = document.querySelector('#notesList .note-card[data-note-id=' +
|
||||||
|
JSON.stringify(${JSON.stringify(String(secondNote.id))}) + ']');
|
||||||
|
if (!target) throw new Error('找不到第二条笔记卡片');
|
||||||
|
const button = [...target.querySelectorAll('.note-action')]
|
||||||
|
.find((item) => item.textContent === '独立窗口');
|
||||||
|
if (!button) throw new Error('第二条笔记没有独立窗口按钮');
|
||||||
|
button.click();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await poll(
|
||||||
|
'第二条笔记进入同一窗口的新标签',
|
||||||
|
async () => noteWindowCount() === 1
|
||||||
|
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 2,
|
||||||
|
15000
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'第二条笔记进入同一窗口的新标签',
|
||||||
|
noteWindowCount() === 1,
|
||||||
|
`笔记窗口数=${noteWindowCount()}`
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'只有一个标签视图可见',
|
||||||
|
(await noteJs(
|
||||||
|
"[...document.querySelectorAll('.note-tab-view')].filter((v) => !v.classList.contains('inactive')).length"
|
||||||
|
)) === 1
|
||||||
|
);
|
||||||
|
// 刚打开且只在编辑区点选、按方向键,不改内容,不能被判定为已修改。
|
||||||
|
// 早前用 pointerdown/keydown 判脏时这里必然误报,一切标签都要求二次确认;
|
||||||
|
// 画布还会因为 version 1→2 归一化在挂载时就"变更"一次
|
||||||
|
await wait(1200);
|
||||||
|
await noteJs(activeScript(`
|
||||||
|
const host = view.querySelector('.note-window-editor');
|
||||||
|
host.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||||
|
host.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
||||||
|
host.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }));
|
||||||
|
host.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
|
||||||
|
return true;
|
||||||
|
`));
|
||||||
|
await wait(900);
|
||||||
|
check(
|
||||||
|
'只点选不改内容不会被误判为已修改',
|
||||||
|
(await noteJs("document.querySelectorAll('.doctab-dirty').length")) === 0,
|
||||||
|
`脏标记数=${await noteJs("document.querySelectorAll('.doctab-dirty').length")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关窗前的未保存拦截:取消之后必须还能再次触发确认。
|
||||||
|
// 主进程 closePending 不复位时第二次点关闭会被静默忽略,
|
||||||
|
// 而看门狗十秒后仍会把带未保存内容的窗口销毁。
|
||||||
|
// 此时激活的是第二条笔记的标签,必须先切回已保存过的那条,
|
||||||
|
// 否则下面"改回原样"比对的是另一条笔记的基线
|
||||||
|
await noteJs(`(() => {
|
||||||
|
const tab = document.querySelector('.doctab[data-note-id=' +
|
||||||
|
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||||
|
if (!tab) throw new Error('找不到目标标签');
|
||||||
|
tab.click();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await poll(
|
||||||
|
'切回已保存的那个标签',
|
||||||
|
() => noteJs(activeScript(
|
||||||
|
`return view.dataset.noteId === ${JSON.stringify(String(windowNote.id))};`
|
||||||
|
)),
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
await noteJs(activeScript(`
|
||||||
|
const title = view.querySelector('.note-window-title');
|
||||||
|
title.value = '关窗前的未保存修改';
|
||||||
|
title.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
`));
|
||||||
|
await poll(
|
||||||
|
'关窗前已置脏',
|
||||||
|
() => noteJs("document.querySelectorAll('.doctab-dirty').length >= 1"),
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
await noteJs("document.getElementById('closeBtn').click()");
|
||||||
|
await poll(
|
||||||
|
'关窗被未保存确认拦下',
|
||||||
|
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
check('关窗被未保存确认拦下,窗口还在', !noteWin.isDestroyed());
|
||||||
|
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
|
||||||
|
await wait(1000);
|
||||||
|
check('取消后窗口保留', !noteWin.isDestroyed());
|
||||||
|
await noteJs("document.getElementById('closeBtn').click()");
|
||||||
|
await poll(
|
||||||
|
'取消之后再次关窗仍会弹确认',
|
||||||
|
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
check('取消之后再次关窗仍会弹确认', !noteWin.isDestroyed());
|
||||||
|
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
|
||||||
|
await wait(800);
|
||||||
|
// 存盘收尾,避免未保存状态干扰后面的删除断言
|
||||||
|
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
|
||||||
|
await poll(
|
||||||
|
'取消关闭后仍能正常保存',
|
||||||
|
() => noteJs(`(() => {
|
||||||
|
const tab = document.querySelector('.doctab[data-note-id=' +
|
||||||
|
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||||
|
return !!tab && !tab.querySelector('.doctab-dirty');
|
||||||
|
})()`),
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
check('取消关闭后仍能正常保存', true);
|
||||||
|
|
||||||
|
// 笔记被删除后只关掉它那个标签,窗口和别的标签要留着
|
||||||
|
await js(`window.api.reader.removeNote(${JSON.stringify(windowNote.entryId)}, ${JSON.stringify(windowNote.id)})`);
|
||||||
|
if (secondNote) {
|
||||||
|
await poll(
|
||||||
|
'删除笔记只关掉对应标签',
|
||||||
|
async () => !noteWin.isDestroyed()
|
||||||
|
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||||
|
12000
|
||||||
|
);
|
||||||
|
check('删除笔记只关掉对应标签,窗口留着', !noteWin.isDestroyed());
|
||||||
|
// 删掉最后一个标签,窗口才该退场
|
||||||
|
const remainingId = await noteJs("document.querySelector('.doctab').dataset.noteId");
|
||||||
|
const remaining = readerStore.listNotes({}).find((note) => note.id === remainingId);
|
||||||
|
check('剩下的标签是另一条笔记', !!remaining && remaining.id !== windowNote.id, String(remainingId));
|
||||||
|
if (remaining) {
|
||||||
|
await js(`window.api.reader.removeNote(${JSON.stringify(remaining.entryId)}, ${JSON.stringify(remaining.id)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await poll('最后一个标签消失后窗口自动关闭', async () => noteWin.isDestroyed(), 12000);
|
||||||
|
check('最后一个标签消失后窗口自动关闭', noteWin.isDestroyed());
|
||||||
|
check('窗口关闭后主进程标签集清空', noteWindow.openIds().length === 0, JSON.stringify(noteWindow.openIds()));
|
||||||
|
|
||||||
await wait(300);
|
await wait(300);
|
||||||
check(
|
check(
|
||||||
'主渲染进程没有控制台错误',
|
'主渲染进程没有控制台错误',
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const EPUB_FILE = path.join(TMP, 'reader-features.epub');
|
|||||||
const MOBI_FILE = path.join(TMP, 'reader-features.mobi');
|
const MOBI_FILE = path.join(TMP, 'reader-features.mobi');
|
||||||
const DRM_MOBI_FILE = path.join(TMP, 'reader-features-drm.azw');
|
const DRM_MOBI_FILE = path.join(TMP, 'reader-features-drm.azw');
|
||||||
const LARGE_EPUB_FILE = path.join(TMP, 'reader-features-large.epub');
|
const LARGE_EPUB_FILE = path.join(TMP, 'reader-features-large.epub');
|
||||||
|
const TXT_FILE = path.join(TMP, 'reader-features.txt');
|
||||||
|
const MD_FILE = path.join(TMP, 'reader-features.md');
|
||||||
app.setPath('userData', TMP);
|
app.setPath('userData', TMP);
|
||||||
app.setPath('appData', TMP);
|
app.setPath('appData', TMP);
|
||||||
|
|
||||||
@@ -194,6 +196,58 @@ function makeMobi(file) {
|
|||||||
fs.writeFileSync(file, output);
|
fs.writeFileSync(file, output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTxt(file) {
|
||||||
|
const lines = ['纯文本夹具标题', ''];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
lines.push(`第${i}章 编码与分章`, '');
|
||||||
|
for (let k = 0; k < 12; k++) {
|
||||||
|
lines.push(`第${i}章第${k + 1}段:TXT-MARK-${i}-${k} 中文正文用于校验解码与全文提取。`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
const text = lines.join('\r\n');
|
||||||
|
// 带 BOM 的 UTF-16LE 是 Windows 记事本另存的默认之一,编码探测必须在真实浏览器里也成立
|
||||||
|
const body = Buffer.from(text, 'utf16le');
|
||||||
|
fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMd(file) {
|
||||||
|
const source = [
|
||||||
|
'# Markdown 夹具',
|
||||||
|
'',
|
||||||
|
'正文段落包含 **加粗** 与 `行内代码`,用于确认渲染而不是纯文本显示。',
|
||||||
|
'',
|
||||||
|
'## 危险内容小节',
|
||||||
|
'',
|
||||||
|
'<script>window.__mdScriptExecuted = true</script>',
|
||||||
|
'',
|
||||||
|
'<img src="x" onerror="window.__mdHandlerExecuted = true">',
|
||||||
|
'',
|
||||||
|
'[不安全链接](javascript:window.__mdLinkExecuted=true)',
|
||||||
|
'',
|
||||||
|
'<iframe src="https://untrusted.example/frame"></iframe>',
|
||||||
|
'',
|
||||||
|
'## 结构小节',
|
||||||
|
'',
|
||||||
|
'- 列表项一',
|
||||||
|
'- 列表项二',
|
||||||
|
'',
|
||||||
|
'```js',
|
||||||
|
'const fenced = "code block";',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'| 列一 | 列二 |',
|
||||||
|
'|---|---|',
|
||||||
|
'| 单元格 | MD-TABLE-CELL |',
|
||||||
|
'',
|
||||||
|
'> 引用块 MD-QUOTE-MARK',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(file, source, 'utf8');
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
async function js(win, source) {
|
async function js(win, source) {
|
||||||
try {
|
try {
|
||||||
return await win.webContents.executeJavaScript(source);
|
return await win.webContents.executeJavaScript(source);
|
||||||
@@ -515,6 +569,8 @@ app.whenReady().then(async () => {
|
|||||||
fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
|
fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
|
||||||
fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
|
fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
|
||||||
fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
|
fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
|
||||||
|
makeTxt(TXT_FILE);
|
||||||
|
makeMd(MD_FILE);
|
||||||
const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
|
const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
|
||||||
drmFixture.writeUInt16BE(1, 96 + 12);
|
drmFixture.writeUInt16BE(1, 96 + 12);
|
||||||
fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
|
fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
|
||||||
@@ -549,7 +605,9 @@ app.whenReady().then(async () => {
|
|||||||
{ path: EPUB_FILE, name: 'reader-features.epub', format: 'EPUB' },
|
{ path: EPUB_FILE, name: 'reader-features.epub', format: 'EPUB' },
|
||||||
{ path: MOBI_FILE, name: 'reader-features.mobi', format: 'MOBI' },
|
{ path: MOBI_FILE, name: 'reader-features.mobi', format: 'MOBI' },
|
||||||
{ path: DRM_MOBI_FILE, name: 'reader-features-drm.azw', format: 'AZW' },
|
{ path: DRM_MOBI_FILE, name: 'reader-features-drm.azw', format: 'AZW' },
|
||||||
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' }
|
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' },
|
||||||
|
{ path: TXT_FILE, name: 'reader-features.txt', format: 'TXT' },
|
||||||
|
{ path: MD_FILE, name: 'reader-features.md', format: 'MD' }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
await wait(1200);
|
await wait(1200);
|
||||||
@@ -671,6 +729,95 @@ app.whenReady().then(async () => {
|
|||||||
pdfWin.setSize(1280, 900);
|
pdfWin.setSize(1280, 900);
|
||||||
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
|
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
|
||||||
.classList.contains('pdfx-layout-single')`);
|
.classList.contains('pdfx-layout-single')`);
|
||||||
|
|
||||||
|
// 前面的分页导航把视口停在第 3 页,离屏页会被回收成空白占位,
|
||||||
|
// 必须先回到第 1 页再测画质,否则量到的是占位画布而不是真实渲染结果
|
||||||
|
await js(pdfWin, `(() => {
|
||||||
|
const range = document.getElementById('progressRange');
|
||||||
|
range.value = '0';
|
||||||
|
range.dispatchEvent(new Event('change'));
|
||||||
|
})()`);
|
||||||
|
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 1 页'`);
|
||||||
|
const canvasProbe = `(() => {
|
||||||
|
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||||
|
const css = canvas.getBoundingClientRect().width;
|
||||||
|
return {
|
||||||
|
dpr: window.devicePixelRatio,
|
||||||
|
backing: canvas.width,
|
||||||
|
css: Math.round(css),
|
||||||
|
ratio: canvas.width / css
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
await waitForJs(pdfWin, `(() => {
|
||||||
|
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||||
|
return canvas && canvas.width > 0;
|
||||||
|
})()`);
|
||||||
|
check('标准画质下第 1 页有真实墨迹',
|
||||||
|
imageHasInk(await js(pdfWin,
|
||||||
|
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`)));
|
||||||
|
const qualityBase = await js(pdfWin, canvasProbe);
|
||||||
|
// 生效倍率是 max(dpr, quality),HiDPI 屏上不叠乘,所以不能断言"backing 翻倍"
|
||||||
|
check('默认画质下 backing 比例等于设备像素比',
|
||||||
|
Math.abs(qualityBase.ratio - qualityBase.dpr) < 0.05,
|
||||||
|
`dpr=${qualityBase.dpr} ratio=${qualityBase.ratio.toFixed(3)}`);
|
||||||
|
const targetQuality = Math.min(3, Math.ceil(qualityBase.dpr + 1));
|
||||||
|
await js(pdfWin, `(() => {
|
||||||
|
const select = document.getElementById('pdfRenderQuality');
|
||||||
|
select.value = '${targetQuality}';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
})()`);
|
||||||
|
await waitForJs(pdfWin, `(() => {
|
||||||
|
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||||
|
if (!canvas || !canvas.width) return false;
|
||||||
|
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${targetQuality}) < 0.05;
|
||||||
|
})()`);
|
||||||
|
const qualityHigh = await js(pdfWin, canvasProbe);
|
||||||
|
// 超采样只能放大 backing store,CSS 盒子必须原地不动,否则是把页面放大而不是提高画质
|
||||||
|
check('PDF 画质档提高 backing 分辨率且不改变版面尺寸',
|
||||||
|
Math.abs(qualityHigh.ratio - targetQuality) < 0.05
|
||||||
|
&& qualityHigh.backing > qualityBase.backing
|
||||||
|
&& Math.abs(qualityHigh.css - qualityBase.css) <= 1,
|
||||||
|
`ratio ${qualityBase.ratio.toFixed(3)}->${qualityHigh.ratio.toFixed(3)} `
|
||||||
|
+ `backing ${qualityBase.backing}->${qualityHigh.backing} css ${qualityBase.css}->${qualityHigh.css}`);
|
||||||
|
const qualityRatios = await js(pdfWin, `Array.from(document.querySelectorAll('.pdfx-canvas'))
|
||||||
|
.filter((canvas) => canvas.width > 0)
|
||||||
|
.map((canvas) => Math.round(canvas.width / canvas.getBoundingClientRect().width * 100) / 100)`);
|
||||||
|
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
|
||||||
|
check('画质切换后同屏各页倍率一致',
|
||||||
|
qualityRatios.length > 0 && new Set(qualityRatios).size === 1,
|
||||||
|
JSON.stringify(qualityRatios));
|
||||||
|
// 画布尺寸在重建时立即变大,墨迹要等这一页重绘完才落上去,只等比例会量到空白中间态
|
||||||
|
let highInk = false;
|
||||||
|
try {
|
||||||
|
await waitUntil(async () => {
|
||||||
|
highInk = imageHasInk(await js(pdfWin,
|
||||||
|
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`));
|
||||||
|
return highInk;
|
||||||
|
}, 15000, 250);
|
||||||
|
} catch (error) { /* 交给下面的断言报告 */ }
|
||||||
|
check('提高画质后仍渲染出真实墨迹而不是空白画布', highInk);
|
||||||
|
await waitUntil(() => Promise.resolve(
|
||||||
|
settings.get('reader.pdfRenderQuality', 0) === targetQuality
|
||||||
|
));
|
||||||
|
check('PDF 画质偏好已持久化', true);
|
||||||
|
await js(pdfWin, `(() => {
|
||||||
|
const select = document.getElementById('pdfRenderQuality');
|
||||||
|
select.value = '1';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
})()`);
|
||||||
|
await waitForJs(pdfWin, `(() => {
|
||||||
|
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||||
|
if (!canvas || !canvas.width) return false;
|
||||||
|
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${qualityBase.dpr}) < 0.05;
|
||||||
|
})()`);
|
||||||
|
check('画质调回标准档后恢复设备像素比', true);
|
||||||
|
// 画质检查需要停在第 1 页,后续用例仍按第 3 页断言,这里把视口还回去
|
||||||
|
await js(pdfWin, `(() => {
|
||||||
|
const range = document.getElementById('progressRange');
|
||||||
|
range.value = '1000';
|
||||||
|
range.dispatchEvent(new Event('change'));
|
||||||
|
})()`);
|
||||||
|
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
|
||||||
try {
|
try {
|
||||||
await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
|
await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
|
||||||
.some((node) => node.firstChild && node.firstChild.data.trim())`);
|
.some((node) => node.firstChild && node.firstChild.data.trim())`);
|
||||||
@@ -1226,7 +1373,7 @@ app.whenReady().then(async () => {
|
|||||||
return progress && progress.locator && progress.locator.kind === 'epub';
|
return progress && progress.locator && progress.locator.kind === 'epub';
|
||||||
}, 5000);
|
}, 5000);
|
||||||
const epubProgress = readerStore.getState(entry.id).progress;
|
const epubProgress = readerStore.getState(entry.id).progress;
|
||||||
const focalOffsetAfter = await js(epubWin, `(() => {
|
const focalAfter = await js(epubWin, `(() => {
|
||||||
const frame = document.querySelector('.host-epub iframe');
|
const frame = document.querySelector('.host-epub iframe');
|
||||||
const doc = frame.contentDocument;
|
const doc = frame.contentDocument;
|
||||||
const outerRect = document.querySelector('.epub-scroll').getBoundingClientRect();
|
const outerRect = document.querySelector('.epub-scroll').getBoundingClientRect();
|
||||||
@@ -1240,18 +1387,31 @@ app.whenReady().then(async () => {
|
|||||||
const range = doc.caretRangeFromPoint(x, y);
|
const range = doc.caretRangeFromPoint(x, y);
|
||||||
if (range) caret = { offsetNode: range.startContainer, offset: range.startOffset };
|
if (range) caret = { offsetNode: range.startContainer, offset: range.startOffset };
|
||||||
}
|
}
|
||||||
if (!caret) return -1;
|
if (!caret) return { offset: -1, charsPerLine: 0 };
|
||||||
|
// 容差要按"一行有多少字"算:字体不同,同样的一行在不同平台字数不同,
|
||||||
|
// 写死字符数的话换个字体集就会假失败
|
||||||
|
let charsPerLine = 0;
|
||||||
|
const host = caret.offsetNode.parentElement;
|
||||||
|
if (host && host.textContent) {
|
||||||
|
const range = doc.createRange();
|
||||||
|
range.selectNodeContents(host);
|
||||||
|
const lines = range.getClientRects().length;
|
||||||
|
if (lines > 0) charsPerLine = host.textContent.length / lines;
|
||||||
|
}
|
||||||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
if (node === caret.offsetNode) return offset + caret.offset;
|
if (node === caret.offsetNode) return { offset: offset + caret.offset, charsPerLine };
|
||||||
offset += node.data.length;
|
offset += node.data.length;
|
||||||
}
|
}
|
||||||
return -1;
|
return { offset: -1, charsPerLine };
|
||||||
})()`);
|
})()`);
|
||||||
|
const focalOffsetAfter = focalAfter.offset;
|
||||||
|
// 焦点锚定的语义是"停在原来那行附近",超过一行就说明锚点真的漂了
|
||||||
|
const focalTolerance = Math.max(12, Math.ceil(focalAfter.charsPerLine));
|
||||||
check('EPUB 双指缩放保持焦点附近字符偏移',
|
check('EPUB 双指缩放保持焦点附近字符偏移',
|
||||||
Math.abs(focalOffsetAfter - pinchResult.anchorOffset) <= 12,
|
Math.abs(focalOffsetAfter - pinchResult.anchorOffset) <= focalTolerance,
|
||||||
`${pinchResult.anchorOffset} -> ${focalOffsetAfter}; 顶部=${epubProgress.locator.offset}`);
|
`${pinchResult.anchorOffset} -> ${focalOffsetAfter}; 容差=${focalTolerance}; 顶部=${epubProgress.locator.offset}`);
|
||||||
|
|
||||||
const epubSelection = await selectEpubText(epubWin);
|
const epubSelection = await selectEpubText(epubWin);
|
||||||
await waitForJs(epubWin, `!document.getElementById('selBar').classList.contains('hidden')`);
|
await waitForJs(epubWin, `!document.getElementById('selBar').classList.contains('hidden')`);
|
||||||
@@ -1356,6 +1516,181 @@ app.whenReady().then(async () => {
|
|||||||
drmReader.errors.slice(0, 3).join(' | '));
|
drmReader.errors.slice(0, 3).join(' | '));
|
||||||
drmReader.win.close();
|
drmReader.win.close();
|
||||||
|
|
||||||
|
const txtReader = await openReader(entry.id, 5);
|
||||||
|
const txtWin = txtReader.win;
|
||||||
|
await waitForJs(txtWin, `!document.querySelector('.doc-overlay')
|
||||||
|
&& !!document.querySelector('.host-epub iframe')?.contentDocument?.body?.textContent.trim()`, 30000);
|
||||||
|
check('TXT 通过内置阅读器打开而不是回退到外部程序',
|
||||||
|
await js(txtWin, `!document.querySelector('.doc-overlay.err')
|
||||||
|
&& !!document.querySelector('.host-epub iframe')`));
|
||||||
|
const txtToc = await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||||
|
.map((button) => button.textContent)`);
|
||||||
|
check('TXT 按章节标题切分并生成可跳转目录',
|
||||||
|
txtToc.length >= 3 && txtToc.some((label) => /第1章/.test(label)),
|
||||||
|
JSON.stringify(txtToc.slice(0, 5)));
|
||||||
|
await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||||
|
.find((button) => /第1章/.test(button.textContent)).click()`);
|
||||||
|
await waitForJs(txtWin, `document.querySelector('.host-epub iframe').contentDocument.body
|
||||||
|
.textContent.includes('TXT-MARK-1-0')`, 20000);
|
||||||
|
const txtDecoded = await js(txtWin, `(() => {
|
||||||
|
const text = document.querySelector('.host-epub iframe').contentDocument.body.textContent;
|
||||||
|
return {
|
||||||
|
chinese: text.includes('第1章第1段'),
|
||||||
|
replacement: text.includes('\\ufffd'),
|
||||||
|
nul: text.includes('\\u0000')
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
// 带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本变乱码且用户无从修正
|
||||||
|
check('带 BOM 的 UTF-16LE 纯文本正确解码为中文而不是乱码',
|
||||||
|
txtDecoded.chinese && !txtDecoded.replacement && !txtDecoded.nul,
|
||||||
|
JSON.stringify(txtDecoded));
|
||||||
|
// AI 的"全文"范围依赖 textOf(locator,'document'),缺章会让模型答非所问且用户无从察觉
|
||||||
|
const txtFullText = await js(txtWin, `(async () => {
|
||||||
|
const module = await import('./reader/text-adapter.mjs');
|
||||||
|
const adapter = module.createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
const bytes = await window.api.reader.bytes(${JSON.stringify(entry.id)}, 5);
|
||||||
|
await adapter.load(bytes.data, {});
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
await adapter.renderTo(host, null, { fontSize: 16, theme: 'light', lineHeight: 1.7 });
|
||||||
|
const full = await adapter.textOf(null, 'document');
|
||||||
|
const page = await adapter.textOf(null, 'page');
|
||||||
|
host.remove();
|
||||||
|
return {
|
||||||
|
marks: (full.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
|
||||||
|
pageMarks: (page.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
|
||||||
|
length: full.length
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
})()`);
|
||||||
|
check('TXT 全文范围覆盖所有章节而不是只有当前章',
|
||||||
|
txtFullText.marks === 36 && txtFullText.pageMarks < txtFullText.marks,
|
||||||
|
JSON.stringify(txtFullText));
|
||||||
|
check('TXT 阅读器没有控制台错误', txtReader.errors.length === 0,
|
||||||
|
txtReader.errors.slice(0, 3).join(' | '));
|
||||||
|
txtWin.close();
|
||||||
|
|
||||||
|
// 上面几条都是直接调 IPC 打开的,绕过了书库界面。
|
||||||
|
// 渲染层自己那份可阅读格式白名单漏掉 txt/md 时,IPC 照样能开,
|
||||||
|
// 但卡片上根本不会出现「阅读」按钮,用户看到的就是"内置阅读器打不开 txt"
|
||||||
|
{
|
||||||
|
const mainWin = BrowserWindow.getAllWindows()
|
||||||
|
.find((w) => !w.isDestroyed() && String(w.webContents.getURL()).includes('index.html'));
|
||||||
|
check('存在主窗口用于校验书库入口', !!mainWin);
|
||||||
|
if (mainWin) {
|
||||||
|
mainWin.show();
|
||||||
|
await js(mainWin, `document.querySelector('.tab[data-tab="library"]').click()`);
|
||||||
|
await waitForJs(mainWin,
|
||||||
|
`document.querySelectorAll('#libGrid > .card').length > 0`, 20000);
|
||||||
|
const cardEntry = await js(mainWin, `(() => {
|
||||||
|
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
|
||||||
|
if (!card) return { missing: true };
|
||||||
|
return {
|
||||||
|
coverReadable: !!card.querySelector('.card-cover.readable'),
|
||||||
|
coverActs: !!card.querySelector('.card-cover[data-act="read"]'),
|
||||||
|
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
|
||||||
|
.some((b) => /阅读/.test(b.title || ''))
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check('书库卡片提供内置阅读入口', cardEntry.hasRead && cardEntry.coverReadable
|
||||||
|
&& cardEntry.coverActs, JSON.stringify(cardEntry));
|
||||||
|
|
||||||
|
// 只含 txt 的条目也要能读:白名单漏项时这条会失败
|
||||||
|
const txtOnly = library.add({
|
||||||
|
title: 'TXT Only Fixture',
|
||||||
|
files: [{ path: TXT_FILE, name: 'txt-only.txt', format: 'TXT' }]
|
||||||
|
});
|
||||||
|
await js(mainWin, `document.getElementById('rescanBtn').click()`);
|
||||||
|
await waitForJs(mainWin,
|
||||||
|
`!!document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]')`, 20000);
|
||||||
|
const txtCard = await js(mainWin, `(() => {
|
||||||
|
const card = document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]');
|
||||||
|
if (!card) return { missing: true };
|
||||||
|
return {
|
||||||
|
coverReadable: !!card.querySelector('.card-cover.readable'),
|
||||||
|
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
|
||||||
|
.some((b) => /阅读/.test(b.title || ''))
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check('纯 TXT 条目在书库里也有阅读入口',
|
||||||
|
txtCard.hasRead && txtCard.coverReadable, JSON.stringify(txtCard));
|
||||||
|
library.remove(txtOnly.id);
|
||||||
|
mainWin.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdReader = await openReader(entry.id, 6);
|
||||||
|
const mdWin = mdReader.win;
|
||||||
|
await waitForJs(mdWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
|
||||||
|
?.textContent.includes('Markdown 夹具')`, 30000);
|
||||||
|
const mdHeadStructure = await js(mdWin, `(() => {
|
||||||
|
const doc = document.querySelector('.host-epub iframe').contentDocument;
|
||||||
|
return {
|
||||||
|
h1: doc.querySelectorAll('h1').length,
|
||||||
|
strong: doc.querySelectorAll('strong').length,
|
||||||
|
code: doc.querySelectorAll('code').length
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check('Markdown 首节渲染成标题与行内标记而不是纯文本',
|
||||||
|
mdHeadStructure.h1 >= 1 && mdHeadStructure.strong >= 1 && mdHeadStructure.code >= 1,
|
||||||
|
JSON.stringify(mdHeadStructure));
|
||||||
|
const mdToc = await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||||
|
.map((button) => button.textContent)`);
|
||||||
|
check('Markdown 按标题层级生成目录',
|
||||||
|
mdToc.length >= 3 && mdToc.some((label) => /结构小节/.test(label)),
|
||||||
|
JSON.stringify(mdToc));
|
||||||
|
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||||
|
.find((button) => /结构小节/.test(button.textContent)).click()`);
|
||||||
|
await waitForJs(mdWin, `document.querySelector('.host-epub iframe').contentDocument
|
||||||
|
.body.textContent.includes('MD-TABLE-CELL')`, 20000);
|
||||||
|
const mdStructure = await js(mdWin, `(() => {
|
||||||
|
const doc = document.querySelector('.host-epub iframe').contentDocument;
|
||||||
|
return {
|
||||||
|
h2: doc.querySelectorAll('h2').length,
|
||||||
|
li: doc.querySelectorAll('li').length,
|
||||||
|
pre: doc.querySelectorAll('pre').length,
|
||||||
|
table: doc.querySelectorAll('table td, table th').length,
|
||||||
|
quote: doc.querySelectorAll('blockquote').length
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check('Markdown 列表、代码块、表格与引用渲染成真实块级结构',
|
||||||
|
mdStructure.h2 >= 1 && mdStructure.li >= 2 && mdStructure.pre >= 1
|
||||||
|
&& mdStructure.table >= 1 && mdStructure.quote >= 1,
|
||||||
|
JSON.stringify(mdStructure));
|
||||||
|
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||||
|
.find((button) => /危险内容小节/.test(button.textContent)).click()`);
|
||||||
|
await wait(1200);
|
||||||
|
// 裸 HTML 被 markdown-it 转义成文本,所以只能按 DOM 断言,
|
||||||
|
// 用 innerHTML 匹配 "onerror" 会把转义后的字面量误判成漏网
|
||||||
|
const mdSafety = await js(mdWin, `(() => {
|
||||||
|
const frame = document.querySelector('.host-epub iframe');
|
||||||
|
const doc = frame.contentDocument;
|
||||||
|
const nodes = Array.from(doc.querySelectorAll('*'));
|
||||||
|
return {
|
||||||
|
script: doc.querySelectorAll('script').length,
|
||||||
|
iframe: doc.querySelectorAll('iframe').length,
|
||||||
|
img: doc.querySelectorAll('img').length,
|
||||||
|
eventAttrs: nodes.filter((node) => Array.from(node.attributes || [])
|
||||||
|
.some((attr) => /^on/i.test(attr.name))).length,
|
||||||
|
unsafeHref: Array.from(doc.querySelectorAll('a[href]'))
|
||||||
|
.filter((a) => /^(javascript|vbscript|data|file):/i.test(a.getAttribute('href') || '')).length,
|
||||||
|
executedScript: !!(frame.contentWindow.__mdScriptExecuted || window.__mdScriptExecuted),
|
||||||
|
executedHandler: !!(frame.contentWindow.__mdHandlerExecuted || window.__mdHandlerExecuted),
|
||||||
|
executedLink: !!(frame.contentWindow.__mdLinkExecuted || window.__mdLinkExecuted)
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check('Markdown 中的脚本、事件属性与 javascript: 链接被净化且未执行',
|
||||||
|
mdSafety.script === 0 && mdSafety.iframe === 0 && mdSafety.img === 0
|
||||||
|
&& mdSafety.eventAttrs === 0 && mdSafety.unsafeHref === 0
|
||||||
|
&& !mdSafety.executedScript && !mdSafety.executedHandler && !mdSafety.executedLink,
|
||||||
|
JSON.stringify(mdSafety));
|
||||||
|
check('Markdown 阅读器没有控制台错误', mdReader.errors.length === 0,
|
||||||
|
mdReader.errors.slice(0, 3).join(' | '));
|
||||||
|
mdWin.close();
|
||||||
|
|
||||||
const readerFile = path.join(TMP, 'reader.json');
|
const readerFile = path.join(TMP, 'reader.json');
|
||||||
const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
|
const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
|
||||||
check('readerStore 使用隔离目录中的 v6 存储',
|
check('readerStore 使用隔离目录中的 v6 存储',
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ app.whenReady().then(async () => {
|
|||||||
const configReadyMs = Date.now() - startedAt;
|
const configReadyMs = Date.now() - startedAt;
|
||||||
check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
|
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);
|
await waitUntil(() => scanStartedAt > 0, 7000);
|
||||||
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
|
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
|
||||||
`${scanStartedAt - startedAt}ms`);
|
`${scanStartedAt - startedAt}ms`);
|
||||||
|
|||||||
@@ -243,6 +243,98 @@ test('failed tag writes roll back both catalog and item references', () => {
|
|||||||
assert.ok(!fs.existsSync(`${file}.bak`));
|
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', () => {
|
test('explicit tag creation enforces the existing catalog limit', () => {
|
||||||
const root = freshRoot('limit');
|
const root = freshRoot('limit');
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -69,13 +69,14 @@ test('handles mixed file and directory inputs while skipping unsupported and non
|
|||||||
const inFolder = write(root, path.join('folder', 'comic.cbz'));
|
const inFolder = write(root, path.join('folder', 'comic.cbz'));
|
||||||
const azw = write(root, path.join('folder', 'legacy.azw'));
|
const azw = write(root, path.join('folder', 'legacy.azw'));
|
||||||
const direct = write(root, 'notes.txt');
|
const direct = write(root, 'notes.txt');
|
||||||
|
const markdown = write(root, path.join('folder', 'guide.MD'));
|
||||||
write(root, path.join('folder', 'cover.jpg'));
|
write(root, path.join('folder', 'cover.jpg'));
|
||||||
write(root, 'README.md');
|
write(root, 'README.rtf');
|
||||||
fs.mkdirSync(path.join(root, 'empty'));
|
fs.mkdirSync(path.join(root, 'empty'));
|
||||||
|
|
||||||
const result = await discover([
|
const result = await discover([
|
||||||
path.join(root, 'missing.pdf'),
|
path.join(root, 'missing.pdf'),
|
||||||
path.join(root, 'README.md'),
|
path.join(root, 'README.rtf'),
|
||||||
path.join(root, 'empty'),
|
path.join(root, 'empty'),
|
||||||
direct,
|
direct,
|
||||||
folder
|
folder
|
||||||
@@ -83,7 +84,8 @@ test('handles mixed file and directory inputs while skipping unsupported and non
|
|||||||
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
result.map((record) => record.path),
|
result.map((record) => record.path),
|
||||||
[inFolder, azw, direct].map((value) => fs.realpathSync(value)).sort()
|
[inFolder, azw, markdown, direct].map((value) => fs.realpathSync(value)).sort(),
|
||||||
|
'md 与 txt 都能进内置阅读器,必须和其他图书格式一样被本地导入发现'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ test('下载校验协议,拒绝 file:// 等非 http(s)', () => {
|
|||||||
assert.ok(/仅允许打开 HTTP 或 HTTPS 链接/.test(mainSrc), 'openExternal 缺协议校验');
|
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('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
|
test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
|
||||||
const start = mainSrc.indexOf("ipcMain.handle('library:remove'");
|
const start = mainSrc.indexOf("ipcMain.handle('library:remove'");
|
||||||
const end = mainSrc.indexOf('// 下载文件', start);
|
const end = mainSrc.indexOf('// 下载文件', start);
|
||||||
@@ -81,6 +87,182 @@ test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
|
|||||||
assert.ok(segment.indexOf('annotations.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('书库列表附带阅读记录中的最近阅读时间', () => {
|
test('书库列表附带阅读记录中的最近阅读时间', () => {
|
||||||
const start = mainSrc.indexOf("ipcMain.handle('library:list'");
|
const start = mainSrc.indexOf("ipcMain.handle('library:list'");
|
||||||
const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
|
const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
|
||||||
@@ -109,6 +291,12 @@ test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => {
|
|||||||
assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/);
|
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*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, /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 主题图标并同步界面主题', () => {
|
test('窗口使用 icons/dist 主题图标并同步界面主题', () => {
|
||||||
@@ -142,6 +330,258 @@ test('标准构建入口固定输出目录并保留便携数据', () => {
|
|||||||
assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/);
|
assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function loadPathResolvers(platform, isPackaged) {
|
||||||
|
return h.extractFns(
|
||||||
|
mainFile,
|
||||||
|
'const APP_ICON_DIR =',
|
||||||
|
'const userDataDir = resolveUserDataDir()',
|
||||||
|
['iconForTheme', 'resolveUserDataDir'],
|
||||||
|
`const path = require('path');
|
||||||
|
const __dirname = ${JSON.stringify(path.join('/opt', 'app'))};
|
||||||
|
const process = { platform: ${JSON.stringify(platform)} };
|
||||||
|
const app = {
|
||||||
|
isPackaged: ${isPackaged},
|
||||||
|
getPath: (key) => {
|
||||||
|
if (key === 'exe') return ${JSON.stringify(path.join('/opt', 'portable', 'PeopleLib.exe'))};
|
||||||
|
if (key === 'appData') return ${JSON.stringify(path.join('/home', 'u', 'AppData'))};
|
||||||
|
throw new Error('未预期的 getPath: ' + key);
|
||||||
|
}
|
||||||
|
};`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('macOS 打包版把用户数据放到 appData,不写进只读的 .app 内部', () => {
|
||||||
|
const appData = path.join('/home', 'u', 'AppData', 'PeopleLib');
|
||||||
|
|
||||||
|
// macOS 上 exe 位于 PeopleLib.app/Contents/MacOS/,DMG 挂载只读,
|
||||||
|
// 且覆盖升级会连同用户书库一起删掉,所以绝不能落在可执行文件旁边
|
||||||
|
assert.strictEqual(loadPathResolvers('darwin', true).resolveUserDataDir(), appData);
|
||||||
|
assert.strictEqual(loadPathResolvers('linux', true).resolveUserDataDir(), appData);
|
||||||
|
|
||||||
|
// Windows 便携版仍旧放在程序同级 data/
|
||||||
|
assert.strictEqual(
|
||||||
|
loadPathResolvers('win32', true).resolveUserDataDir(),
|
||||||
|
path.join('/opt', 'portable', 'data')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const platform of ['darwin', 'win32']) {
|
||||||
|
assert.strictEqual(loadPathResolvers(platform, false).resolveUserDataDir(), appData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口图标按平台取用,macOS 不会拿到 .ico', () => {
|
||||||
|
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||||||
|
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const win = loadPathResolvers('win32', true).iconForTheme(theme);
|
||||||
|
assert.strictEqual(path.extname(win), '.ico');
|
||||||
|
assert.strictEqual(path.basename(win), `book-ai-${theme}.ico`);
|
||||||
|
|
||||||
|
const mac = loadPathResolvers('darwin', true).iconForTheme(theme);
|
||||||
|
assert.strictEqual(path.extname(mac), '.png');
|
||||||
|
// 打包脚本只复制 32 与 256 两个尺寸,取用的那个必须真实存在
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(iconRoot, theme, path.basename(mac))),
|
||||||
|
`缺少 macOS 窗口图标 ${theme}/${path.basename(mac)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未知主题回落到 dark,不能拼出不存在的路径
|
||||||
|
assert.strictEqual(
|
||||||
|
path.basename(loadPathResolvers('darwin', true).iconForTheme('nope')),
|
||||||
|
'icon-256.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('icns 图标容器结构合法且尺寸齐全', () => {
|
||||||
|
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||||||
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
const buf = fs.readFileSync(path.join(iconRoot, `book-ai-${theme}.icns`));
|
||||||
|
assert.strictEqual(buf.toString('latin1', 0, 4), 'icns', `${theme} 魔数错误`);
|
||||||
|
// 长度字段写错时 Finder 会静默显示默认图标,必须逐字节核对
|
||||||
|
assert.strictEqual(buf.readUInt32BE(4), buf.length, `${theme} 长度字段与文件不符`);
|
||||||
|
|
||||||
|
const slots = new Map();
|
||||||
|
for (let off = 8; off < buf.length;) {
|
||||||
|
const type = buf.toString('latin1', off, off + 4);
|
||||||
|
const len = buf.readUInt32BE(off + 4);
|
||||||
|
assert.ok(len >= 8 && off + len <= buf.length, `${theme} 槽 ${type} 长度非法`);
|
||||||
|
const payload = buf.subarray(off + 8, off + len);
|
||||||
|
assert.ok(payload.subarray(0, 8).equals(PNG), `${theme} 槽 ${type} 不是 PNG`);
|
||||||
|
slots.set(type, payload.readUInt32BE(16));
|
||||||
|
off += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, size] of [['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024]]) {
|
||||||
|
assert.strictEqual(slots.get(type), size, `${theme} 缺少 ${type}/${size}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图标产物不被 dist 规则忽略,构建脚本引用的文件都在仓库里', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const ignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8');
|
||||||
|
|
||||||
|
// 不加前导斜杠时 dist/ 会连 icons/dist/ 一起忽略,
|
||||||
|
// 新克隆的仓库缺少图标,构建直接失败
|
||||||
|
assert.match(ignore, /^\/dist\/$/m, '.gitignore 的 dist 规则必须锚定到仓库根');
|
||||||
|
assert.doesNotMatch(ignore, /^dist\/$/m);
|
||||||
|
|
||||||
|
const portable = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
|
||||||
|
const mac = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
|
||||||
|
const required = [
|
||||||
|
'book-ai-dark.ico', 'book-ai-light.ico',
|
||||||
|
'book-ai-dark.icns',
|
||||||
|
path.join('dark', 'icon-32.png'), path.join('light', 'icon-32.png'),
|
||||||
|
path.join('dark', 'icon-256.png'), path.join('light', 'icon-256.png')
|
||||||
|
];
|
||||||
|
for (const rel of required) {
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(root, 'icons', 'dist', rel)),
|
||||||
|
`构建需要的图标缺失: icons/dist/${rel}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(portable.includes('book-ai-dark.ico'));
|
||||||
|
assert.ok(mac.includes('book-ai-dark.icns'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS 打包脚本保留签名前提并产出 arm64 DMG', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const build = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
|
||||||
|
|
||||||
|
assert.strictEqual(pkg.scripts['build:mac'], 'node build-mac.js');
|
||||||
|
assert.match(build, /const TARGET = `\$\{PRODUCT\}-macos-\$\{ARCH\}`/);
|
||||||
|
assert.match(build, /const ARCH = 'arm64'/);
|
||||||
|
|
||||||
|
// 交叉打包做不出可用产物:hdiutil 与 codesign 都只有 macOS 才有
|
||||||
|
assert.match(build, /process\.platform !== 'darwin'/);
|
||||||
|
for (const tool of ['hdiutil', 'codesign', 'ditto']) assert.ok(build.includes(tool), `缺少 ${tool} 检查`);
|
||||||
|
|
||||||
|
// Electron Framework 带符号链接,用 Node 解压会展开成副本并让签名失效
|
||||||
|
assert.match(build, /run\('ditto', \['-x', '-k', zip, cacheDir\]\)/);
|
||||||
|
|
||||||
|
// 重打包后 asar 哈希对不上,留着该字段会启动即报完整性错误
|
||||||
|
assert.match(build, /ElectronAsarIntegrity/);
|
||||||
|
// 官方 helper plist 没有 CFBundleExecutable,改名后必须补上
|
||||||
|
assert.match(build, /CFBundleExecutable/);
|
||||||
|
// 带版本的 framework 要签 Versions/A;Squirrel 里的 ShipIt 是独立可执行文件
|
||||||
|
assert.match(build, /Versions', 'A'/);
|
||||||
|
assert.match(build, /ShipIt/);
|
||||||
|
// 签名必须由内向外,外层 .app 最后签
|
||||||
|
assert.match(build, /targets\.push\(APP_BUNDLE\)/);
|
||||||
|
assert.match(build, /'--sign', '-'/);
|
||||||
|
assert.match(build, /'--verify', '--deep', '--strict'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux 打包直接从官方 zip 转写 tar 并保住可执行位', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const linux = require(path.join(root, 'build-linux.js'));
|
||||||
|
|
||||||
|
assert.strictEqual(pkg.scripts['build:linux'], 'node build-linux.js');
|
||||||
|
|
||||||
|
// 主程序、沙箱与共享库丢了执行位就起不来;普通资源不该被误判成可执行
|
||||||
|
for (const name of ['PeopleLib', 'chrome-sandbox', 'chrome_crashpad_handler', 'libffmpeg.so']) {
|
||||||
|
assert.ok(linux.isExecutableName(name), `${name} 应带可执行位`);
|
||||||
|
}
|
||||||
|
for (const name of ['resources/app/main.js', 'locales/zh-CN.pak', 'icudtl.dat']) {
|
||||||
|
assert.ok(!linux.isExecutableName(name), `${name} 不该带可执行位`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jszip 给出的权限位优先,缺失时才按文件名兜底
|
||||||
|
assert.strictEqual(linux.modeOf({ unixPermissions: 0o644 }, 'chrome-sandbox'), 0o644);
|
||||||
|
assert.strictEqual(linux.modeOf({}, 'chrome-sandbox'), 0o755);
|
||||||
|
assert.strictEqual(linux.modeOf({ unixPermissions: null }, 'resources/app/main.js'), 0o644);
|
||||||
|
|
||||||
|
assert.strictEqual(linux.parseArch(['--arch', 'arm64']), 'arm64');
|
||||||
|
assert.throws(() => linux.parseArch(['--arch', 'mips']), /不支持的架构/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux tar 条目对超长路径用 PAX 头,权限位可被标准解析读回', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const linux = require(path.join(root, 'build-linux.js'));
|
||||||
|
const { readTarEntries } = require(path.join(root, 'build-release.js'));
|
||||||
|
|
||||||
|
// ustar 的 prefix 只能在斜杠处切分,undici 深层路径切不出合法组合
|
||||||
|
const long = `PeopleLib-linux-x64/resources/app/node_modules/undici/lib/web/${'d'.repeat(60)}/x.js`;
|
||||||
|
assert.ok(Buffer.byteLength(long) > 100);
|
||||||
|
|
||||||
|
const buffer = Buffer.concat([
|
||||||
|
linux.tarEntry('PeopleLib-linux-x64/PeopleLib', 0o755, Buffer.from('bin')),
|
||||||
|
linux.tarEntry(long, 0o644, Buffer.from('src')),
|
||||||
|
Buffer.alloc(1024)
|
||||||
|
]);
|
||||||
|
const entries = readTarEntries(buffer);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(entries.map((e) => e.name), ['PeopleLib-linux-x64/PeopleLib', long]);
|
||||||
|
assert.strictEqual(entries[0].mode & 0o111, 0o111);
|
||||||
|
assert.strictEqual(entries[1].mode & 0o111, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('发布打包排除便携版 data 目录并按校验和回验', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const release = fs.readFileSync(path.join(root, 'build-release.js'), 'utf8');
|
||||||
|
const { TARGETS } = require(path.join(root, 'build-release.js'));
|
||||||
|
|
||||||
|
assert.strictEqual(pkg.scripts.release, 'node build-release.js');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
Object.keys(TARGETS).sort(),
|
||||||
|
['linux-arm64', 'linux-x64', 'macos-arm64', 'windows-x64']
|
||||||
|
);
|
||||||
|
// 发布件名字带版本号,Release 页面上不同版本的资产才不会互相覆盖
|
||||||
|
for (const [key, target] of Object.entries(TARGETS)) {
|
||||||
|
assert.ok(target.asset.includes(pkg.version), `${key} 发布件名缺少版本号`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 便携版书库就在构建目录同级 data/,漏掉这条会把用户数据打进公开发布件
|
||||||
|
assert.match(release, /!f\.rel\.startsWith\('data\/'\)/);
|
||||||
|
assert.match(release, /forbidEntries\(names, \[\/\^data\\\/\/, \/\(\^\|\\\/\)_test\\\/\/\]\)/);
|
||||||
|
// 跨平台产物汇总时逐个比对哈希与版本,防止挂上损坏或版本错配的资产
|
||||||
|
assert.match(release, /if \(digest !== file\.sha256\) throw new Error/);
|
||||||
|
assert.match(release, /manifest\.version !== VERSION/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CI 工作流覆盖三平台并在标签上核对版本后发布', () => {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
const workflow = fs.readFileSync(path.join(root, '.github', 'workflows', 'build.yml'), 'utf8');
|
||||||
|
|
||||||
|
for (const target of ['windows-2025', 'macos-15', 'ubuntu-24.04', 'ubuntu-24.04-arm']) {
|
||||||
|
assert.ok(workflow.includes(target), `构建矩阵缺少 ${target}`);
|
||||||
|
}
|
||||||
|
// 集成测试要开真实窗口,无头 runner 上没有 xvfb 会直接崩
|
||||||
|
assert.match(workflow, /xvfb-run -a npx electron/);
|
||||||
|
for (const suite of ['startup', 'download', 'cover', 'annotation', 'reader-features', 'library-notes', 'ai-scope']) {
|
||||||
|
assert.ok(workflow.includes(suite), `CI 缺少集成套件 ${suite}`);
|
||||||
|
}
|
||||||
|
for (const suite of fs.readdirSync(path.join(root, 'src', '_test', 'electron'))) {
|
||||||
|
if (!suite.endsWith('.integration.js')) continue;
|
||||||
|
assert.ok(
|
||||||
|
workflow.includes(suite.replace('.integration.js', '')),
|
||||||
|
`CI 漏跑集成套件 ${suite}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仓库 .npmrc 指向 npmmirror,GitHub runner 在境外必须切回官方源
|
||||||
|
assert.match(fs.readFileSync(path.join(root, '.npmrc'), 'utf8'), /registry\.npmmirror\.com/);
|
||||||
|
assert.match(workflow, /npm_config_registry: https:\/\/registry\.npmjs\.org\//);
|
||||||
|
assert.match(workflow, /ELECTRON_MIRROR: https:\/\/github\.com\/electron\/electron\/releases\/download\//);
|
||||||
|
|
||||||
|
// 打标签才发布,且发布前要求标签名与 package.json 版本一致
|
||||||
|
assert.match(workflow, /if: github\.event_name == 'push' && github\.ref_type == 'tag'/);
|
||||||
|
assert.match(workflow, /标签应为/);
|
||||||
|
assert.ok(workflow.includes(`v'+p.version`));
|
||||||
|
assert.match(workflow, /--verify dist\/release-downloads/);
|
||||||
|
// 先建草稿再转正式,上传中途失败不会留下资产不全的 Release
|
||||||
|
assert.match(workflow, /gh release edit "\$tag" --draft --verify-tag/);
|
||||||
|
assert.match(workflow, /gh release edit "\$tag" --draft=false/);
|
||||||
|
assert.ok(pkg.version && /^\d+\.\d+\.\d+$/.test(pkg.version));
|
||||||
|
});
|
||||||
|
|
||||||
test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => {
|
test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => {
|
||||||
assert.match(mainSrc, /await requestReaderPurge\(id\)/);
|
assert.match(mainSrc, /await requestReaderPurge\(id\)/);
|
||||||
assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);
|
assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);
|
||||||
@@ -179,10 +619,10 @@ test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌',
|
|||||||
assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
|
assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('内置阅读器允许 PDF、EPUB 和无 DRM Kindle 容器并保留外部回退', () => {
|
test('内置阅读器允许 PDF、EPUB、无 DRM Kindle 容器与纯文本并保留外部回退', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
mainSrc,
|
mainSrc,
|
||||||
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/
|
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/
|
||||||
);
|
);
|
||||||
assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
|
assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
|
||||||
assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
|
assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const adapterFile = path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs');
|
||||||
|
const src = fs.readFileSync(adapterFile, 'utf8');
|
||||||
|
|
||||||
|
// pdf-adapter.mjs 直接 import vendor 的 pdf.min.mjs,在 Node 里加载会因缺 DOMMatrix 而抛错,
|
||||||
|
// 所以只把不依赖 DOM 的钳制段落切出来求值,断言的仍是生产源码本身。
|
||||||
|
function loadPureBlock() {
|
||||||
|
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||||
|
const end = src.indexOf('const CSS = `');
|
||||||
|
assert.ok(start >= 0 && end > start, '找不到画布上限段落,钳制代码可能被移动或删除');
|
||||||
|
const block = src.slice(start, end).replace(/^export /gm, '');
|
||||||
|
assert.doesNotMatch(
|
||||||
|
block,
|
||||||
|
/\b(window|document|navigator)\b/,
|
||||||
|
'钳制逻辑必须是纯函数,一旦依赖 DOM 就无法在单测里做真实数值断言'
|
||||||
|
);
|
||||||
|
return new Function(`${block}\nreturn { clampCanvasSize, clampRenderQuality };`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clampCanvasSize, clampRenderQuality } = loadPureBlock();
|
||||||
|
|
||||||
|
const MAX_SIDE = 16384;
|
||||||
|
const MAX_AREA = 268435456;
|
||||||
|
|
||||||
|
function assertWithinLimits(out, label) {
|
||||||
|
assert.ok(
|
||||||
|
out.width <= MAX_SIDE && out.height <= MAX_SIDE,
|
||||||
|
`${label} 单边 ${out.width}x${out.height} 超过 ${MAX_SIDE},浏览器会静默给出不可用画布,整页空白且不报错`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
out.width * out.height <= MAX_AREA,
|
||||||
|
`${label} 面积 ${out.width * out.height} 超过 ${MAX_AREA},同样会静默失败`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
out.width >= 1 && out.height >= 1,
|
||||||
|
`${label} 钳制后出现 ${out.width}x${out.height},0 尺寸画布会让 getContext 之后的绘制全部丢弃`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('A4 常规缩放与超采样不触发钳制', () => {
|
||||||
|
// 612x792pt 的 A4 @ scale1.2,倍率 2:1468x1900,远在上限内
|
||||||
|
const out = clampCanvasSize(612 * 1.2, 792 * 1.2, 2);
|
||||||
|
assert.strictEqual(out.clamped, false, '常规页被误钳会白白牺牲清晰度');
|
||||||
|
assert.strictEqual(out.quality, 2, '未触发上限时必须原样保留名义倍率');
|
||||||
|
assert.strictEqual(out.width, 1468, `期望 1468 实际 ${out.width}`);
|
||||||
|
assert.strictEqual(out.height, 1900, `期望 1900 实际 ${out.height}`);
|
||||||
|
assertWithinLimits(out, 'A4@1.2x2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A4 最高缩放叠加 dpr3 仍不触发钳制', () => {
|
||||||
|
// 实测 8929x12628(113M)是可用的,钳制不该把这一档也压下去
|
||||||
|
const out = clampCanvasSize(612 * 5, 792 * 5, 3);
|
||||||
|
assert.strictEqual(out.clamped, false, '这一档实测可用,钳制过度会让高缩放反而更糊');
|
||||||
|
assert.strictEqual(out.width, 9180, `期望 9180 实际 ${out.width}`);
|
||||||
|
assert.strictEqual(out.height, 11880, `期望 11880 实际 ${out.height}`);
|
||||||
|
assertWithinLimits(out, 'A4@5x3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2000pt 大幅面 @ scale5 dpr2 被钳回上限内', () => {
|
||||||
|
// 期望 backing 20000x14000(280M),实测不可用
|
||||||
|
const out = clampCanvasSize(2000 * 5, 1400 * 5, 2);
|
||||||
|
assert.strictEqual(out.clamped, true, '280M 画布必须被识别为超限,否则用户只看到空白页且没有任何提示');
|
||||||
|
assertWithinLimits(out, '2000pt@5x2');
|
||||||
|
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限以保留最大清晰度,实际 ${out.width}`);
|
||||||
|
assert.ok(out.quality < 2, `生效倍率 ${out.quality} 应低于名义倍率 2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4000pt 图纸 @ scale3 dpr2 被钳回上限内', () => {
|
||||||
|
// 期望 backing 24000x18000(432M),实测不可用
|
||||||
|
const out = clampCanvasSize(4000 * 3, 3000 * 3, 2);
|
||||||
|
assert.strictEqual(out.clamped, true, '432M 画布必须被识别为超限');
|
||||||
|
assertWithinLimits(out, '4000pt@3x2');
|
||||||
|
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限,实际 ${out.width}`);
|
||||||
|
assert.strictEqual(out.height, 12288, `期望 12288 实际 ${out.height}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('钳制后的实际比例如实报告,供 render 的 transform 使用', () => {
|
||||||
|
const cssWidth = 4000 * 3;
|
||||||
|
const cssHeight = 3000 * 3;
|
||||||
|
const out = clampCanvasSize(cssWidth, cssHeight, 2);
|
||||||
|
// transform 用名义倍率而不是取整后的实际比例,画面会错位或只画出一角
|
||||||
|
assert.strictEqual(out.scaleX, out.width / cssWidth, 'scaleX 必须等于 backing 宽除以 CSS 宽');
|
||||||
|
assert.strictEqual(out.scaleY, out.height / cssHeight, 'scaleY 必须等于 backing 高除以 CSS 高');
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(out.scaleX - out.scaleY) < 1e-6,
|
||||||
|
`等比钳制下 x/y 比例应基本一致,实际 ${out.scaleX} / ${out.scaleY}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('短边的取整损失也要反映到比例上,不能直接沿用生效倍率', () => {
|
||||||
|
// 长边正好压到上限时它的比例恰等于生效倍率,只有短边能暴露 floor 带来的偏差
|
||||||
|
const out = clampCanvasSize(16000, 17000, 1);
|
||||||
|
assert.strictEqual(out.scaleX, out.width / 16000, 'scaleX 必须按 floor 之后的实际宽算');
|
||||||
|
assert.notStrictEqual(
|
||||||
|
out.scaleX,
|
||||||
|
out.quality,
|
||||||
|
`短边 ${out.width}px 的实际比例应当略小于生效倍率 ${out.quality},直接沿用会让内容画出画布边界`
|
||||||
|
);
|
||||||
|
assert.strictEqual(out.scaleY, out.height / 17000, 'scaleY 必须按 floor 之后的实际高算');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取整误差不会让某一边掉到 0 像素', () => {
|
||||||
|
const out = clampCanvasSize(100000, 1, 1);
|
||||||
|
assertWithinLimits(out, '极端长条页');
|
||||||
|
assert.strictEqual(out.height, 1, `期望至少 1px,实际 ${out.height}`);
|
||||||
|
assert.strictEqual(out.scaleY, 1, 'scaleY 必须按补足到 1px 后的实际比例报告,否则内容会被压到画布外');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('长边超限而面积未超限的页也被钳', () => {
|
||||||
|
// 20000x2000 = 40M,面积远未超限,只有单边超限
|
||||||
|
const out = clampCanvasSize(20000, 2000, 1);
|
||||||
|
assert.strictEqual(out.clamped, true, '只超单边同样不可用,不能只看面积');
|
||||||
|
assertWithinLimits(out, '超长单边');
|
||||||
|
assert.strictEqual(out.width, MAX_SIDE, `期望 ${MAX_SIDE} 实际 ${out.width}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('接近正方形的超大页同时压住面积与单边', () => {
|
||||||
|
// 16000x17000 = 272M,面积超限且短边已经贴着上限,是最容易只压一头的形状
|
||||||
|
const out = clampCanvasSize(16000, 17000, 1);
|
||||||
|
assert.strictEqual(out.clamped, true, '272M 面积必须被识别为超限');
|
||||||
|
assertWithinLimits(out, '16000x17000');
|
||||||
|
assert.strictEqual(out.height, MAX_SIDE, `长边应压到上限,实际 ${out.height}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('面积约束是真在起作用,而不是被单边约束顺带盖住', () => {
|
||||||
|
// 当前常量下 MAX_AREA 恰好等于 MAX_SIDE 的平方,压住长边就顺带压住了面积,
|
||||||
|
// 面积检查的价值只有在上限常量按别的浏览器口径调整时才显现。
|
||||||
|
// 这里把单边上限换成 65535 重新求值,确认删掉面积项会立刻放出 600M 的画布。
|
||||||
|
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||||
|
const end = src.indexOf('const CSS = `');
|
||||||
|
const block = src.slice(start, end)
|
||||||
|
.replace(/^export /gm, '')
|
||||||
|
.replace('const MAX_CANVAS_SIDE = 16384;', 'const MAX_CANVAS_SIDE = 65535;');
|
||||||
|
const wide = new Function(`${block}\nreturn clampCanvasSize;`)();
|
||||||
|
const out = wide(30000, 20000, 1);
|
||||||
|
assert.ok(
|
||||||
|
out.width * out.height <= MAX_AREA,
|
||||||
|
`面积 ${out.width * out.height} 超过 ${MAX_AREA}:面积约束没有独立生效,只靠单边约束挡不住扁平的大幅面页`
|
||||||
|
);
|
||||||
|
assert.strictEqual(out.clamped, true, '面积超限也必须如实上报,否则外壳无法解释清晰度为何被降');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大范围尺寸与倍率组合下上限恒成立', () => {
|
||||||
|
const sides = [1, 200, 612, 792, 1190, 2384, 5000, 10000, 20000, 40000];
|
||||||
|
const qualities = [1, 1.25, 1.5, 2, 2.5, 3, 4];
|
||||||
|
const scales = [0.25, 1, 1.2, 2, 3, 5];
|
||||||
|
for (const w of sides) {
|
||||||
|
for (const h of sides) {
|
||||||
|
for (const q of qualities) {
|
||||||
|
for (const s of scales) {
|
||||||
|
const out = clampCanvasSize(w * s, h * s, q);
|
||||||
|
const label = `${w}x${h} @scale${s} @${q}x`;
|
||||||
|
assertWithinLimits(out, label);
|
||||||
|
assert.ok(
|
||||||
|
out.quality <= q + 1e-9,
|
||||||
|
`${label}:生效倍率 ${out.quality} 不该超过名义倍率 ${q}`
|
||||||
|
);
|
||||||
|
assert.strictEqual(out.scaleX, out.width / (w * s), `${label}:scaleX 与实际 backing 宽不符`);
|
||||||
|
assert.strictEqual(out.scaleY, out.height / (h * s), `${label}:scaleY 与实际 backing 高不符`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法尺寸与倍率退回安全值而不是抛错或产出 NaN', () => {
|
||||||
|
for (const bad of [undefined, null, NaN, 0, -5, 'abc', Infinity]) {
|
||||||
|
const out = clampCanvasSize(bad, bad, bad);
|
||||||
|
assert.ok(
|
||||||
|
Number.isInteger(out.width) && Number.isInteger(out.height),
|
||||||
|
`尺寸 ${String(bad)} 产出了非整数 ${out.width}x${out.height},canvas.width 赋 NaN 会静默变 0`
|
||||||
|
);
|
||||||
|
assertWithinLimits(out, `非法输入 ${String(bad)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderQuality 只接受 1 到 4,非法值回退到 1', () => {
|
||||||
|
assert.strictEqual(clampRenderQuality(1), 1);
|
||||||
|
assert.strictEqual(clampRenderQuality(2), 2);
|
||||||
|
assert.strictEqual(clampRenderQuality(4), 4);
|
||||||
|
assert.strictEqual(clampRenderQuality(1.5), 1.5, '允许非整数档位,1.5x 实测已能显著降低误差');
|
||||||
|
assert.strictEqual(clampRenderQuality(8), 4, '超过 4 倍换不来可感知的清晰度,只会成倍吃显存');
|
||||||
|
assert.strictEqual(clampRenderQuality(0.5), 1, '低于 1 会比现状更糊');
|
||||||
|
for (const bad of [undefined, null, NaN, 'abc', {}, Infinity, -Infinity]) {
|
||||||
|
assert.strictEqual(
|
||||||
|
clampRenderQuality(bad),
|
||||||
|
1,
|
||||||
|
`非法值 ${String(bad)} 必须回退到 1,回退到 NaN 会让整块 backing 计算失效`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死画布上限常量与两条约束', () => {
|
||||||
|
assert.match(src, /const MAX_CANVAS_SIDE = 16384;/, 'Chromium 单边上限,改动前必须先实测');
|
||||||
|
assert.match(src, /const MAX_CANVAS_AREA = 268435456;/, 'Chromium 总面积上限');
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/Math\.min\(want, bySide, byArea\)/,
|
||||||
|
'单边与面积两条约束都要参与,只留一条在上限常量变动后就会漏放超限画布'
|
||||||
|
);
|
||||||
|
assert.match(src, /MAX_CANVAS_SIDE \/ Math\.max\(cssWidth, cssHeight\)/, '单边约束按长边算');
|
||||||
|
assert.match(src, /Math\.sqrt\(MAX_CANVAS_AREA \/ \(cssWidth \* cssHeight\)\)/, '面积约束按开方算');
|
||||||
|
assert.match(src, /Math\.max\(1, Math\.floor\(cssWidth \* applied\)\)/, '钳制后至少保留 1px');
|
||||||
|
assert.match(src, /Math\.max\(1, Math\.floor\(cssHeight \* applied\)\)/, '钳制后至少保留 1px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死超采样倍率的取值与生效方式', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/Math\.max\(1, Math\.min\(4, n\)\)/,
|
||||||
|
'renderQuality 必须被 clamp 到 1..4'
|
||||||
|
);
|
||||||
|
// 一次锁死整条链路:dpr 取大而不是叠乘、期望倍率必须过钳制、画布尺寸只能来自钳制结果
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const dpr = window\.devicePixelRatio \|\| 1;\s*\n\s*const wanted = Math\.max\(dpr, clampRenderQuality\(renderQuality\)\);\s*\n\s*const fit = clampCanvasSize\(vp\.width, vp\.height, wanted\);\s*\n\s*p\.canvas\.width = fit\.width;\s*\n\s*p\.canvas\.height = fit\.height;/,
|
||||||
|
'有效倍率必须是 max(dpr, renderQuality) 且画布尺寸只能取钳制结果,绕过任一步都会重新引入静默空白页'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
[...src.matchAll(/Math\.max\(dpr, clampRenderQuality\(renderQuality\)\)/g)].length,
|
||||||
|
2,
|
||||||
|
'renderPage 与 renderStats 必须用同一个有效倍率公式,否则界面报告的清晰度和实际渲染的不一致'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/transform: identity \? null : \[fit\.scaleX, 0, 0, fit\.scaleY, 0, 0\]/,
|
||||||
|
'transform 必须用钳制后的实际比例,用名义倍率会画错位或只画出一角'
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
src,
|
||||||
|
/transform: dpr === 1 \? null : \[dpr, 0, 0, dpr, 0, 0\]/,
|
||||||
|
'旧的 dpr 直接当 transform 的写法在钳制生效时会画错'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死倍率变化触发全页重建', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/if \(nextScale !== scale \|\| nextQuality !== renderQuality\) \{\s*\n\s*epoch\+\+;/,
|
||||||
|
'倍率变化不走 epoch++ 重建,同屏会残留旧清晰度的页'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/nextQuality = opts && opts\.renderQuality !== undefined\s*\n?\s*\? clampRenderQuality\(opts\.renderQuality\)\s*\n?\s*: renderQuality/,
|
||||||
|
'缺省 renderQuality 时必须沿用当前值,否则每次渲染都会把用户设置重置成 1'
|
||||||
|
);
|
||||||
|
assert.match(src, /renderStats\(\)/, 'renderStats 是外壳读取当前清晰度状态的唯一正当出口');
|
||||||
|
assert.doesNotMatch(src, /window\.__test/, '不允许为测试往生产代码加全局钩子');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 截图路径同样受画布上限保护', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const fit = clampCanvasSize\(area\.width, area\.height, wantScale\);/,
|
||||||
|
'大幅面页在 renderScale 下限 1 时画布等于页面点尺寸,MediaBox 异常的文件会顶到上限'
|
||||||
|
);
|
||||||
|
assert.match(src, /const renderScale = fit\.quality;/, '截图的 viewport 与 transform 必须用钳制后的倍率');
|
||||||
|
assert.match(src, /canvas\.width = fit\.width;/, '截图画布尺寸直接取钳制结果');
|
||||||
|
// 图像只保留一条 JPEG 编码路径,钳制不该顺手引入格式回退
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
[...src.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]),
|
||||||
|
[],
|
||||||
|
'编码仍应集中在 visual-context.mjs,适配器里不该出现新的编码路径'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -93,6 +93,34 @@ test('不同条目的阅读数据互相隔离', () => {
|
|||||||
assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
|
assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('孤立阅读资料按笔记数报告并可批量回收', () => {
|
||||||
|
const s = freshStore();
|
||||||
|
s.setProgress('kept', { kind: 'pdf', page: 2 }, 0.5);
|
||||||
|
s.addNote('kept', { text: '保留的笔记' });
|
||||||
|
s.addNote('gone_a', { text: '甲一' });
|
||||||
|
s.addNote('gone_a', { text: '甲二' });
|
||||||
|
s.addBookmark('gone_b', { locator: { kind: 'pdf', page: 3 } });
|
||||||
|
s.addStandaloneNote({ text: '与书籍无关的独立笔记' });
|
||||||
|
|
||||||
|
const orphans = s.orphanReport(['kept']);
|
||||||
|
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
|
||||||
|
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').notes, 2);
|
||||||
|
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_b').bookmarks, 1);
|
||||||
|
// 独立笔记本没有对应书籍,永远不算孤立
|
||||||
|
assert.ok(!orphans.some((o) => o.entryId === s.STANDALONE_ENTRY_ID));
|
||||||
|
|
||||||
|
assert.strictEqual(s.forgetMany(orphans.map((o) => o.entryId)), 2);
|
||||||
|
assert.strictEqual(s.getState('kept').notes.length, 1);
|
||||||
|
assert.strictEqual(s.getState('kept').progress.percent, 0.5);
|
||||||
|
assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记'));
|
||||||
|
assert.deepStrictEqual(s.orphanReport(['kept']), []);
|
||||||
|
assert.strictEqual(s.forgetMany([]), 0);
|
||||||
|
|
||||||
|
// 即使显式点名,也不能删掉独立笔记本:它没有对应书籍,永远不是可回收对象
|
||||||
|
assert.strictEqual(s.forgetMany([s.STANDALONE_ENTRY_ID]), 0);
|
||||||
|
assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记'));
|
||||||
|
});
|
||||||
|
|
||||||
test('getState 返回副本,外部改动不污染存储', () => {
|
test('getState 返回副本,外部改动不污染存储', () => {
|
||||||
const s = freshStore();
|
const s = freshStore();
|
||||||
s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } });
|
s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } });
|
||||||
@@ -623,6 +651,41 @@ test('笔记可更新且必需保留正文或引用', () => {
|
|||||||
assert.strictEqual(s.updateNote('e1', 'nt_missing', { title: 'x' }), null);
|
assert.strictEqual(s.updateNote('e1', 'nt_missing', { title: 'x' }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('普通读书笔记新增与更新均完整保留超长正文', () => {
|
||||||
|
const d = tmp();
|
||||||
|
let s = storeAt(d);
|
||||||
|
const addedText = '新增正文'.repeat(6000);
|
||||||
|
const updatedText = '更新正文'.repeat(6500);
|
||||||
|
const note = s.addNote('e1', { text: addedText, source: 'manual' });
|
||||||
|
assert.strictEqual(note.text, addedText);
|
||||||
|
assert.strictEqual(s.getState('e1').notes[0].text, addedText);
|
||||||
|
|
||||||
|
s = storeAt(d);
|
||||||
|
assert.strictEqual(s.getState('e1').notes[0].text, addedText);
|
||||||
|
const updated = s.updateNote('e1', note.id, { text: updatedText });
|
||||||
|
assert.strictEqual(updated.text, updatedText);
|
||||||
|
|
||||||
|
s = storeAt(d);
|
||||||
|
assert.strictEqual(s.getState('e1').notes[0].text, updatedText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('富文本派生的普通笔记正文不截断', () => {
|
||||||
|
const s = freshStore();
|
||||||
|
const addedText = '富文本新增'.repeat(5000);
|
||||||
|
const updatedText = '富文本更新'.repeat(5500);
|
||||||
|
const note = s.addNote('e1', {
|
||||||
|
richContent: { version: 2, ops: [{ insert: `${addedText}\n` }] }
|
||||||
|
});
|
||||||
|
assert.strictEqual(note.text, addedText);
|
||||||
|
assert.strictEqual(note.richContent.ops[0].insert, `${addedText}\n`);
|
||||||
|
|
||||||
|
const updated = s.updateNote('e1', note.id, {
|
||||||
|
richContent: { version: 2, ops: [{ insert: `${updatedText}\n` }] }
|
||||||
|
});
|
||||||
|
assert.strictEqual(updated.text, updatedText);
|
||||||
|
assert.strictEqual(updated.richContent.ops[0].insert, `${updatedText}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
test('笔记本名称不区分大小写去重,删除后笔记移入未分类', () => {
|
test('笔记本名称不区分大小写去重,删除后笔记移入未分类', () => {
|
||||||
const s = freshStore();
|
const s = freshStore();
|
||||||
const collection = s.addCollection({ name: 'Research' });
|
const collection = s.addCollection({ name: 'Research' });
|
||||||
@@ -698,6 +761,7 @@ test('无关联笔记独立持久化且不计入书库卡片笔记数', () => {
|
|||||||
test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
|
test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
|
||||||
const d = tmp();
|
const d = tmp();
|
||||||
const file = path.join(d, 'reader.json');
|
const file = path.join(d, 'reader.json');
|
||||||
|
const legacyText = '旧版超长正文'.repeat(4000);
|
||||||
const legacy = {
|
const legacy = {
|
||||||
entries: {
|
entries: {
|
||||||
e1: {
|
e1: {
|
||||||
@@ -705,7 +769,7 @@ test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
|
|||||||
bookmarks: [{ id: 'bm_old', locator: { page: 8 }, at: 11 }],
|
bookmarks: [{ id: 'bm_old', locator: { page: 8 }, at: 11 }],
|
||||||
notes: [{
|
notes: [{
|
||||||
id: 'nt_old',
|
id: 'nt_old',
|
||||||
text: '旧笔记',
|
text: legacyText,
|
||||||
quote: '旧引用',
|
quote: '旧引用',
|
||||||
kind: 'ai',
|
kind: 'ai',
|
||||||
at: 123,
|
at: 123,
|
||||||
@@ -720,6 +784,7 @@ test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
|
|||||||
assert.deepStrictEqual(state.progress, legacy.entries.e1.progress);
|
assert.deepStrictEqual(state.progress, legacy.entries.e1.progress);
|
||||||
assert.deepStrictEqual(state.bookmarks, legacy.entries.e1.bookmarks);
|
assert.deepStrictEqual(state.bookmarks, legacy.entries.e1.bookmarks);
|
||||||
assert.strictEqual(state.notes[0].id, 'nt_old');
|
assert.strictEqual(state.notes[0].id, 'nt_old');
|
||||||
|
assert.strictEqual(state.notes[0].text, legacyText);
|
||||||
assert.strictEqual(state.notes[0].source, 'ai');
|
assert.strictEqual(state.notes[0].source, 'ai');
|
||||||
assert.strictEqual(state.notes[0].createdAt, 123);
|
assert.strictEqual(state.notes[0].createdAt, 123);
|
||||||
assert.strictEqual(state.notes[0].updatedAt, 123);
|
assert.strictEqual(state.notes[0].updatedAt, 123);
|
||||||
@@ -750,7 +815,7 @@ test('字段限制、来源校验和安全 ID 校验生效', () => {
|
|||||||
text: 'x'.repeat(25000),
|
text: 'x'.repeat(25000),
|
||||||
tags: Array.from({ length: 40 }, (_, i) => `tag-${i}`)
|
tags: Array.from({ length: 40 }, (_, i) => `tag-${i}`)
|
||||||
});
|
});
|
||||||
assert.strictEqual(note.text.length, 20000);
|
assert.strictEqual(note.text.length, 25000);
|
||||||
assert.strictEqual(note.tags.length, 30);
|
assert.strictEqual(note.tags.length, 30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const sources = require('../sources');
|
|||||||
|
|
||||||
test('注册表:每个源都实现完整接口', () => {
|
test('注册表:每个源都实现完整接口', () => {
|
||||||
const list = sources.listSources();
|
const list = sources.listSources();
|
||||||
assert.ok(list.length >= 12);
|
assert.ok(list.length >= 16);
|
||||||
|
assert.strictEqual(new Set(list.map((source) => source.id)).size, list.length, '数据源 ID 不能重复');
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
const m = sources.getSource(s.id);
|
const m = sources.getSource(s.id);
|
||||||
for (const fn of ['list', 'search', 'detail', 'download']) {
|
for (const fn of ['list', 'search', 'detail', 'download']) {
|
||||||
@@ -17,6 +18,13 @@ test('注册表:每个源都实现完整接口', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 抛错', () => {
|
test('注册表:未知 id 抛错', () => {
|
||||||
assert.throws(() => sources.getSource('nope'), /未知数据源/);
|
assert.throws(() => sources.getSource('nope'), /未知数据源/);
|
||||||
});
|
});
|
||||||
@@ -275,6 +283,233 @@ test('arxiv: 解析 atom feed 并取 pdf 链接', async () => {
|
|||||||
assert.strictEqual(d.files[0].link, 'https://arxiv.org/pdf/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 ---
|
// --- Z-Library ---
|
||||||
|
|
||||||
test('zlib: postId 缺 hash 时详情仍可用', async () => {
|
test('zlib: postId 缺 hash 时详情仍可用', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,710 @@
|
|||||||
|
// TXT / Markdown 适配器单测。适配器本体是渲染层 ESM,这里用 jsdom 提供真实的
|
||||||
|
// window / document / DOMParser,并加载仓库里真正会随包发布的 vendor 脚本,
|
||||||
|
// 这样断言的是「真正交给 epub 适配器渲染的产物」,而不是桩。
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', 'ui', 'reader', 'text-adapter.mjs');
|
||||||
|
const VENDOR = path.join(__dirname, '..', 'ui', 'vendor');
|
||||||
|
const XHTML = 'application/xhtml+xml';
|
||||||
|
|
||||||
|
function setupDom() {
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||||
|
const win = dom.window;
|
||||||
|
win.JSZip = require(path.join(VENDOR, 'jszip.min.js'));
|
||||||
|
win.markdownit = require(path.join(VENDOR, 'markdown-it.min.js'));
|
||||||
|
const purifyFactory = require(path.join(VENDOR, 'purify.min.js'));
|
||||||
|
win.DOMPurify = typeof purifyFactory === 'function' ? purifyFactory(win) : purifyFactory;
|
||||||
|
for (const key of ['window', 'document', 'DOMParser', 'XMLSerializer', 'NodeFilter', 'Node', 'Range']) {
|
||||||
|
globalThis[key] = key === 'window' ? win : win[key];
|
||||||
|
}
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = setupDom();
|
||||||
|
const mod = import('../ui/reader/text-adapter.mjs');
|
||||||
|
|
||||||
|
function u8(...parts) {
|
||||||
|
const buffers = parts.map((part) => (typeof part === 'string'
|
||||||
|
? Buffer.from(part, 'utf8')
|
||||||
|
: Buffer.from(part)));
|
||||||
|
return new Uint8Array(Buffer.concat(buffers));
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf16Bytes(text, littleEndian, bom) {
|
||||||
|
const out = [];
|
||||||
|
if (bom) out.push(...(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]));
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const code = text.charCodeAt(i);
|
||||||
|
const hi = code >> 8;
|
||||||
|
const lo = code & 0xff;
|
||||||
|
out.push(...(littleEndian ? [lo, hi] : [hi, lo]));
|
||||||
|
}
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// tidy 与 epub 适配器里的同名函数一致,textOf 返回的是 tidy 之后的文本
|
||||||
|
function tidy(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.replace(/[ \t\f\v\u00a0]+/g, ' ')
|
||||||
|
.replace(/ ?\n ?/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function novel(sections) {
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 1; i <= sections; i++) {
|
||||||
|
parts.push(`第${i}章 标题${i}\n【标记${String(i).padStart(4, '0')}】这一节的正文内容,用来验证全文不缺不重。\n`);
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChapter(zipBytes, index) {
|
||||||
|
const zip = await win.JSZip.loadAsync(zipBytes);
|
||||||
|
const file = zip.file(`text/chapter-${index}.xhtml`);
|
||||||
|
assert.ok(file, `产物里应有 text/chapter-${index}.xhtml`);
|
||||||
|
return file.async('text');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXhtml(text) {
|
||||||
|
const doc = new win.DOMParser().parseFromString(text, XHTML);
|
||||||
|
assert.ok(doc && doc.body && !doc.querySelector('parsererror'),
|
||||||
|
'生成的章节必须是合法 XHTML,否则 epub 适配器会退回容错 HTML 解析,行为不可预测');
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditUntrusted(doc, where) {
|
||||||
|
for (const selector of ['script', 'iframe', 'object', 'embed', 'img', 'link', 'meta', 'form', 'base', 'a']) {
|
||||||
|
assert.strictEqual(doc.querySelectorAll(selector).length, 0,
|
||||||
|
`${where} 不允许出现 <${selector}>:这是不可信内容的注入面`);
|
||||||
|
}
|
||||||
|
for (const el of doc.querySelectorAll('*')) {
|
||||||
|
for (const attr of Array.from(el.attributes)) {
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
assert.ok(!name.startsWith('on'),
|
||||||
|
`${where} 出现事件属性 ${name},脚本会在阅读器里执行`);
|
||||||
|
assert.ok(!['href', 'src', 'xlink:href', 'srcset', 'poster', 'style', 'formaction'].includes(name),
|
||||||
|
`${where} 出现可发起加载或导航的属性 ${name}`);
|
||||||
|
assert.ok(!/(?:javascript|vbscript|data|file):/i.test(attr.value),
|
||||||
|
`${where} 属性 ${name} 里出现危险协议`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 编码检测与解码 --- */
|
||||||
|
|
||||||
|
test('UTF-8 与 UTF-16 的 BOM 都能正确剥离,BOM 不得留在正文里', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
const text = '中文标题\n正文第一行\n';
|
||||||
|
|
||||||
|
const plain = decodeTextBytes(u8(text));
|
||||||
|
assert.strictEqual(plain.text, text, 'UTF-8 无 BOM 必须原样解出');
|
||||||
|
assert.strictEqual(plain.encoding, 'utf-8');
|
||||||
|
assert.strictEqual(plain.confident, true);
|
||||||
|
|
||||||
|
const withBom = decodeTextBytes(u8([0xef, 0xbb, 0xbf], text));
|
||||||
|
assert.strictEqual(withBom.text, text, 'UTF-8 BOM 必须被剥掉,留着会变成正文首个不可见字符');
|
||||||
|
assert.ok(!withBom.text.includes('\ufeff'), 'U+FEFF 不能出现在正文里');
|
||||||
|
|
||||||
|
const le = decodeTextBytes(utf16Bytes(text, true, true));
|
||||||
|
assert.strictEqual(le.text, text, 'UTF-16LE 带 BOM 必须正确解出中文');
|
||||||
|
assert.strictEqual(le.encoding, 'utf-16le');
|
||||||
|
assert.ok(!le.text.includes('\ufeff'));
|
||||||
|
|
||||||
|
const be = decodeTextBytes(utf16Bytes(text, false, true));
|
||||||
|
assert.strictEqual(be.text, text, 'UTF-16BE 带 BOM 必须正确解出中文');
|
||||||
|
assert.strictEqual(be.encoding, 'utf-16be');
|
||||||
|
assert.ok(!be.text.includes('\ufeff'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('双 BOM 也要剥干净:TextDecoder 只吃掉第一个', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
// 某些编辑器另存为会叠一层 BOM。TextDecoder 只认最前面那个,第二个会变成正文首字符,
|
||||||
|
// 章节标题匹配随即失效,目录莫名少一章。
|
||||||
|
const text = '第1章 标题\n正文\n';
|
||||||
|
assert.strictEqual(decodeTextBytes(u8([0xef, 0xbb, 0xbf], [0xef, 0xbb, 0xbf], text)).text, text);
|
||||||
|
const le = utf16Bytes(text, true, true);
|
||||||
|
assert.strictEqual(decodeTextBytes(u8([0xff, 0xfe], le)).text, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无 BOM 的 UTF-16LE 靠 NUL 分布嗅探,不能当成乱码', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
const text = 'Chapter 1\nHello world, this is plain ASCII text stored as UTF-16LE.\n';
|
||||||
|
const out = decodeTextBytes(utf16Bytes(text, true, false));
|
||||||
|
assert.strictEqual(out.encoding, 'utf-16le');
|
||||||
|
assert.strictEqual(out.text, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GBK 字节走 gb18030 解码,不产生替换字符', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
// 「这是一段简体中文测试」的 GBK 编码
|
||||||
|
const gbk = new Uint8Array([
|
||||||
|
0xd5, 0xe2, 0xca, 0xc7, 0xd2, 0xbb, 0xb6, 0xce, 0xbc, 0xf2,
|
||||||
|
0xcc, 0xe5, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4,
|
||||||
|
0x0a
|
||||||
|
]);
|
||||||
|
const out = decodeTextBytes(gbk);
|
||||||
|
assert.strictEqual(out.text, '这是一段简体中文测试\n', 'GBK 必须解成可读中文');
|
||||||
|
assert.strictEqual(out.encoding, 'gb18030');
|
||||||
|
assert.strictEqual(out.confident, true);
|
||||||
|
assert.ok(!out.text.includes('\ufffd'), '解对了就不该有 U+FFFD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('解不出任何像样编码时标记 confident=false,不让用户对着乱码猜', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
// 0x81 后跟 0x1f 在 GBK / Big5 里都是非法尾字节,cp1252 里则落进 C1 控制区
|
||||||
|
const noise = new Uint8Array(512);
|
||||||
|
for (let i = 0; i < noise.length; i++) noise[i] = i % 2 ? 0x1f : 0x81;
|
||||||
|
const out = decodeTextBytes(noise);
|
||||||
|
assert.strictEqual(out.confident, false, '低置信度必须上报,否则界面会静默显示乱码');
|
||||||
|
|
||||||
|
for (const [name, bytes] of [
|
||||||
|
['UTF-8 中文', u8('正常的中文文本内容。\n')],
|
||||||
|
['GBK 中文', new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0x0a])],
|
||||||
|
['纯 ASCII', u8('Plain english text.\n')]
|
||||||
|
]) {
|
||||||
|
assert.strictEqual(decodeTextBytes(bytes).confident, true,
|
||||||
|
`${name} 不能被误判成低置信度,否则提示会变成噪声,用户就不再看它了`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('打分只认确定是正文的字符,拉丁扩展区乱码一律不得正分', async () => {
|
||||||
|
const { textScore, decodeTextBytes } = await mod;
|
||||||
|
const chinese = '这是一段正常的简体中文正文内容。';
|
||||||
|
const mojibake = Buffer.from(chinese, 'utf8').toString('latin1');
|
||||||
|
assert.ok(textScore(chinese) > 0, 'ASCII 与中文正文必须是正分');
|
||||||
|
assert.ok(textScore('Plain ASCII sentence.') > 0, 'ASCII 正文必须是正分');
|
||||||
|
|
||||||
|
// 乱码必须 <= 0 而不只是「低于正确解码」。一个汉字按 cp1252 摊成三个拉丁扩展字符,
|
||||||
|
// 只要这些字符拿到任何正分,乱码就靠字符数优势翻盘:实测把 0xa0..0x24f 记 1 分后,
|
||||||
|
// 同一段 GBK 正文的 windows-1252 得分从 0 涨到 320,与 gb18030 打平并靠顺序取胜。
|
||||||
|
for (const [name, text] of [
|
||||||
|
['UTF-8 中文按 cp1252 解出的乱码', mojibake],
|
||||||
|
['纯拉丁扩展区噪声', new TextDecoder('windows-1252')
|
||||||
|
.decode(new Uint8Array(Array.from({ length: 300 }, (_, i) => 0xa0 + (i % 0x40))))]
|
||||||
|
]) {
|
||||||
|
assert.ok(textScore(text) <= 0,
|
||||||
|
`${name} 得分为 ${textScore(text)},必须 <= 0,否则乱码会冒充识别成功`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gbk = new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4, 0x0a]);
|
||||||
|
assert.strictEqual(decodeTextBytes(gbk).encoding, 'gb18030',
|
||||||
|
'简体中文是本应用主场,不能被 windows-1252 的乱码抢走');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空文件与超限文件都有确定行为', async () => {
|
||||||
|
const { decodeTextBytes, MAX_TEXT_BYTES } = await mod;
|
||||||
|
const empty = decodeTextBytes(new Uint8Array(0));
|
||||||
|
assert.strictEqual(empty.text, '');
|
||||||
|
assert.strictEqual(empty.confident, true);
|
||||||
|
assert.throws(
|
||||||
|
() => decodeTextBytes(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||||
|
/超过 64 MB/,
|
||||||
|
'超限必须抛中文错误,界面直接展示这句话'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CR LF 归一,C0 控制符被剔除', async () => {
|
||||||
|
const { decodeTextBytes } = await mod;
|
||||||
|
const out = decodeTextBytes(u8('第一行\r\n第二行\r第三行\u0000\u0007\n'));
|
||||||
|
assert.strictEqual(out.text, '第一行\n第二行\n第三行\n',
|
||||||
|
'C0 控制符留在正文会让生成的 XHTML 解析失败,正文随即退回容错解析');
|
||||||
|
const tabbed = decodeTextBytes(u8('列一\t列二\n'));
|
||||||
|
assert.strictEqual(tabbed.text, '列一\t列二\n', 'Tab 是合法 XML 字符,不能顺手删掉');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- 章节切分 --- */
|
||||||
|
|
||||||
|
test('TXT 按中文章节标题切分,边界落在标题行首', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const source = novel(6);
|
||||||
|
const out = splitPlainText(source);
|
||||||
|
assert.strictEqual(out.chapters.length, 6, '6 个「第N章」应切成 6 章');
|
||||||
|
assert.deepStrictEqual(out.chapters.map((c) => c.label),
|
||||||
|
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6'],
|
||||||
|
'章节名必须取自正文里的标题行');
|
||||||
|
assert.strictEqual(out.chapters[0].start, 0);
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source,
|
||||||
|
'各章拼回来必须与原文逐字相同,否则全文一定缺字或重字');
|
||||||
|
for (const chapter of out.chapters) {
|
||||||
|
assert.strictEqual(source.slice(chapter.start, chapter.end), chapter.text, '偏移必须与切片一致');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('第一个章节标题之前的正文单独成章,不能被丢掉', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const preamble = '书名:某本小说\n作者:某人\n版权声明若干。\n\n';
|
||||||
|
const source = preamble + novel(4);
|
||||||
|
const out = splitPlainText(source);
|
||||||
|
assert.strictEqual(out.chapters.length, 5, '开头 + 4 章 = 5 章');
|
||||||
|
assert.strictEqual(out.chapters[0].label, '开头');
|
||||||
|
assert.strictEqual(out.chapters[0].text, preamble,
|
||||||
|
'首个标题之前的内容必须完整保留,否则序言与版权页在全文里凭空消失');
|
||||||
|
assert.strictEqual(out.chapters[1].label, '第1章 标题1');
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('标题不足三个时不认,按长度分节,序号从 1 开始', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const source = `第1章 只有一个标题\n${'一二三四五六七八九十'.repeat(200)}\n`.repeat(1);
|
||||||
|
const out = splitPlainText(source, { target: 500 });
|
||||||
|
assert.ok(out.chapters.length >= 1);
|
||||||
|
assert.match(out.chapters[0].label, /^第 1 节$/, '未识别到章节结构时用「第 N 节」');
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空文件、纯空白、单行超长都不崩且能拼回原文', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const empty = splitPlainText('');
|
||||||
|
assert.strictEqual(empty.chapters.length, 1);
|
||||||
|
assert.strictEqual(empty.chapters[0].text, '');
|
||||||
|
assert.strictEqual(empty.chapters[0].label, '正文');
|
||||||
|
|
||||||
|
const blank = splitPlainText(' \n\n\t \n');
|
||||||
|
assert.ok(blank.chapters.length >= 1);
|
||||||
|
assert.strictEqual(blank.chapters.map((c) => c.text).join(''), ' \n\n\t \n');
|
||||||
|
|
||||||
|
const huge = 'x'.repeat(200000);
|
||||||
|
const one = splitPlainText(huge, { target: 1000, max: 5000 });
|
||||||
|
assert.ok(one.chapters.length > 1, '没有换行的超长文本也必须切开,否则单章会撑爆 DOM');
|
||||||
|
assert.strictEqual(one.chapters.map((c) => c.text).join(''), huge);
|
||||||
|
assert.ok(one.chapters.every((c) => c.text.length <= 5000), '每章不得超过上限');
|
||||||
|
assert.ok(one.chapters.slice(1).every((c) => /(续)$/.test(c.label)), '硬切出来的后续片段要标注(续)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('章节数不超过上限,避免打出上万个 zip 条目', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
const source = novel(400);
|
||||||
|
const out = splitPlainText(source, { maxChapters: 50 });
|
||||||
|
assert.ok(out.chapters.length <= 50, `实际 ${out.chapters.length} 章,超过 maxChapters`);
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source, '合并章节不能丢正文');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- Markdown 结构 --- */
|
||||||
|
|
||||||
|
test('Markdown 标题生成正确的目录层级', async () => {
|
||||||
|
const { createMarkdownRenderer, splitMarkdown, buildTocEntries, markdownHeadings } = await mod;
|
||||||
|
const md = createMarkdownRenderer(win.markdownit);
|
||||||
|
const source = [
|
||||||
|
'# 总标题', '', '开场白。', '',
|
||||||
|
'## 第一节', '', '内容一。', '',
|
||||||
|
'### 一点一', '', '内容二。', '',
|
||||||
|
'## 第二节', '', '内容三。', '',
|
||||||
|
'```', '# 代码块里的井号不是标题', '```', ''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const headings = markdownHeadings(source, md);
|
||||||
|
assert.deepStrictEqual(headings.map((h) => [h.level, h.label]), [
|
||||||
|
[1, '总标题'], [2, '第一节'], [3, '一点一'], [2, '第二节']
|
||||||
|
], '代码块里的 # 不能被当成标题');
|
||||||
|
|
||||||
|
const split = splitMarkdown(source, md);
|
||||||
|
assert.deepStrictEqual(split.chapters.map((c) => c.label),
|
||||||
|
['总标题', '第一节', '第二节'], '默认按 h1/h2 切章,h3 留在章内');
|
||||||
|
assert.strictEqual(split.title, '总标题');
|
||||||
|
assert.strictEqual(split.chapters.map((c) => c.text).join(''), source);
|
||||||
|
|
||||||
|
const { entries, anchorsByChapter } = buildTocEntries(split.chapters, split.headings, split.splitLevel);
|
||||||
|
assert.deepStrictEqual(entries.map((e) => [e.label, e.depth, e.chapter]), [
|
||||||
|
['总标题', 0, 0], ['第一节', 1, 1], ['一点一', 2, 1], ['第二节', 1, 2]
|
||||||
|
], 'depth 必须等于标题级别减一,目录才能正确缩进');
|
||||||
|
assert.strictEqual(anchorsByChapter.get(1)[0], '', '章首标题跳章即可,不需要锚点');
|
||||||
|
assert.ok(anchorsByChapter.get(1)[1], '章内标题必须有锚点,否则目录只能跳到章首');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('锚点数量与渲染出的标题数不符时不打 id,避免锚点指到错误标题', async () => {
|
||||||
|
const { createMarkdownRenderer, markdownChapterXhtml, resolveServices } = await mod;
|
||||||
|
const md = createMarkdownRenderer(win.markdownit);
|
||||||
|
const services = resolveServices();
|
||||||
|
const chapter = { label: '甲', text: '# 一\n\n正文\n\n## 二\n', start: 0, end: 20 };
|
||||||
|
|
||||||
|
const mismatched = markdownChapterXhtml(chapter, services, md, ['only-one']);
|
||||||
|
assert.ok(!/\bid="/.test(mismatched),
|
||||||
|
'章节边界可能落进代码围栏,使章内真实标题数与预估不符;此时按序号打 id 会让目录跳到错误的标题,宁可放弃锚点');
|
||||||
|
assert.strictEqual(parseXhtml(mismatched).querySelectorAll('h1,h2').length, 2, '放弃锚点不等于放弃正文');
|
||||||
|
|
||||||
|
const matched = markdownChapterXhtml(chapter, services, md, ['a1', 'a2']);
|
||||||
|
const ids = Array.from(parseXhtml(matched).querySelectorAll('[id]')).map((el) => el.getAttribute('id'));
|
||||||
|
assert.deepStrictEqual(ids, ['a1', 'a2'], '数量对得上时必须按文档顺序打 id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有 h1/h2 的 Markdown 回退到按长度分节,标题列表仍然可用', async () => {
|
||||||
|
const { createMarkdownRenderer, splitMarkdown } = await mod;
|
||||||
|
const md = createMarkdownRenderer(win.markdownit);
|
||||||
|
const source = `### 只有三级标题\n\n${'正文内容。'.repeat(50)}\n`;
|
||||||
|
const out = splitMarkdown(source, md);
|
||||||
|
assert.ok(out.chapters.length >= 1, '无 h1/h2 也要有章节');
|
||||||
|
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||||
|
assert.strictEqual(out.headings.length, 1, 'h3 仍应出现在目录里');
|
||||||
|
assert.strictEqual(out.title, '只有三级标题');
|
||||||
|
|
||||||
|
const bare = splitMarkdown('只有一行正文,没有任何标题。', md);
|
||||||
|
assert.strictEqual(bare.chapters.length, 1);
|
||||||
|
assert.ok(bare.chapters[0].label, '无标题时也要有可展示的章节名');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目录嵌套标记闭合正确,epub 目录解析器按 li > ol 递归', async () => {
|
||||||
|
const { navMarkup } = await mod;
|
||||||
|
const html = navMarkup([
|
||||||
|
{ label: 'A', depth: 0, href: 'a' },
|
||||||
|
{ label: 'A1', depth: 1, href: 'a1' },
|
||||||
|
{ label: 'A2', depth: 1, href: 'a2' },
|
||||||
|
{ label: 'B', depth: 0, href: 'b' }
|
||||||
|
]);
|
||||||
|
assert.strictEqual(html,
|
||||||
|
'<ol><li><a href="a">A</a><ol><li><a href="a1">A1</a></li><li><a href="a2">A2</a></li></ol></li><li><a href="b">B</a></li></ol>');
|
||||||
|
const doc = new win.DOMParser().parseFromString(`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||||
|
assert.ok(!doc.querySelector('parsererror'), '目录标记必须是合法 XHTML');
|
||||||
|
assert.strictEqual(doc.querySelectorAll('li').length, 4);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('li > ol > li').length, 2, '子级必须挂在父级 li 里');
|
||||||
|
|
||||||
|
const skipped = navMarkup([{ label: 'X', depth: 3, href: 'x' }]);
|
||||||
|
assert.strictEqual(skipped, '<ol><li><a href="x">X</a></li></ol>', '首条深度跳跃要收敛到顶层,不能生成孤立 ol');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('任意 depth 序列生成的目录都是闭合的合法 XHTML,且条目一个不少', async () => {
|
||||||
|
const { navMarkup } = await mod;
|
||||||
|
const cases = [
|
||||||
|
[0], [1], [0, 1, 2, 3, 2, 1, 0], [2, 0, 2], [0, 0, 0],
|
||||||
|
[0, 2, 1, 3, 0], [1, 1, 0, 5, 0], [3, 3, 3], []
|
||||||
|
];
|
||||||
|
for (const depths of cases) {
|
||||||
|
const entries = depths.map((depth, i) => ({ label: `L${i}`, depth, href: `h${i}` }));
|
||||||
|
const html = navMarkup(entries);
|
||||||
|
const doc = new win.DOMParser().parseFromString(
|
||||||
|
`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||||
|
assert.ok(!doc.querySelector('parsererror'),
|
||||||
|
`depth 序列 [${depths}] 生成了非法 XHTML:${html};标记不闭合会让整个 nav.xhtml 解析失败,目录直接退化成按章编号`);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('li').length, depths.length,
|
||||||
|
`depth 序列 [${depths}] 的目录条目数应为 ${depths.length}`);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('ol > li').length, depths.length,
|
||||||
|
`depth 序列 [${depths}] 里每个 li 都必须直接挂在 ol 下`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- 净化 --- */
|
||||||
|
|
||||||
|
const EVIL_MARKDOWN = [
|
||||||
|
'# 标题',
|
||||||
|
'',
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
'',
|
||||||
|
'<img src=x onerror=alert(1)>',
|
||||||
|
'',
|
||||||
|
'[链接](javascript:alert(1))',
|
||||||
|
'',
|
||||||
|
'<iframe src="http://evil.example"></iframe>',
|
||||||
|
'',
|
||||||
|
'<div onmouseover="alert(1)">悬停</div>',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'[正常外链](https://example.com/page)',
|
||||||
|
'',
|
||||||
|
'<svg><use xlink:href="http://evil.example/x#a" /></svg>',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
test('sanitizeMarkdownFragment 直接吃裸 HTML 也必须净化干净', async () => {
|
||||||
|
const { sanitizeMarkdownFragment, resolveServices } = await mod;
|
||||||
|
const services = resolveServices();
|
||||||
|
const fragment = sanitizeMarkdownFragment([
|
||||||
|
'<h1 id="x">标题</h1>',
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
'<img src=x onerror=alert(1)>',
|
||||||
|
'<iframe src="http://evil.example"></iframe>',
|
||||||
|
'<a href="javascript:alert(1)">链接</a>',
|
||||||
|
'<p style="background:url(http://evil.example/x)">样式</p>',
|
||||||
|
'<object data="x"></object><embed src="x" /><form action="x"></form>',
|
||||||
|
'<p onclick="alert(1)">点我</p>'
|
||||||
|
].join(''), services);
|
||||||
|
const host = win.document.createElement('div');
|
||||||
|
host.appendChild(fragment);
|
||||||
|
const doc = parseXhtml(`<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head><body>${new win.XMLSerializer().serializeToString(host)}</body></html>`);
|
||||||
|
auditUntrusted(doc, '净化后的片段');
|
||||||
|
assert.ok(doc.body.textContent.includes('标题'), '净化不能把正常正文一起吃掉');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('恶意 Markdown 渲染出的章节里没有可执行内容', async () => {
|
||||||
|
const { buildTextEpub } = await mod;
|
||||||
|
const built = await buildTextEpub(u8(EVIL_MARKDOWN), { format: 'md' });
|
||||||
|
const xhtml = await readChapter(built.bytes, 0);
|
||||||
|
const doc = parseXhtml(xhtml);
|
||||||
|
auditUntrusted(doc, 'Markdown 章节');
|
||||||
|
|
||||||
|
const text = doc.body.textContent;
|
||||||
|
assert.ok(text.includes('alert(1)'),
|
||||||
|
'危险内容应被转义成可见文字而不是静默删除,用户才知道原文写了什么');
|
||||||
|
assert.ok(text.includes('图片:图注'), '图片必须换成占位符,绝不外链加载');
|
||||||
|
assert.ok(text.includes('正常外链'), '外链文字要保留,只是不再可点');
|
||||||
|
assert.ok(!/<script|<iframe|<img|<svg/i.test(xhtml), '序列化结果里不允许出现这些标签');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源码锁死两道防线:markdown-it html:false 与放行名单里没有 href/src', async () => {
|
||||||
|
const src = fs.readFileSync(SRC, 'utf8');
|
||||||
|
assert.match(src, /markdownit\(\{[\s\S]{0,400}?html: false/,
|
||||||
|
'html:false 是第一道防线,去掉后裸 HTML 会直接进入渲染管线');
|
||||||
|
const allowed = src.match(/const ALLOWED_ATTR = Object\.freeze\(\[([^\]]*)\]\)/);
|
||||||
|
assert.ok(allowed, '必须显式声明放行属性名单');
|
||||||
|
assert.ok(!/href|src|style|srcset|on[a-z]/i.test(allowed[1]),
|
||||||
|
`放行名单不能含加载或导航类属性,当前为 ${allowed[1]}`);
|
||||||
|
assert.match(src, /RETURN_DOM_FRAGMENT: true/, '必须以片段形式取回净化结果,避免二次解析引入 mXSS');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- 端到端:契约方法 --- */
|
||||||
|
|
||||||
|
test('TXT 走完整管线:章节数、目录、locator kind 与全文', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const source = novel(6);
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
const progress = [];
|
||||||
|
try {
|
||||||
|
const info = await adapter.load(u8(source), { onProgress: (p) => progress.push(p) });
|
||||||
|
assert.strictEqual(info.format, 'txt');
|
||||||
|
assert.strictEqual(info.mode, 'plain');
|
||||||
|
assert.strictEqual(info.encoding, 'utf-8');
|
||||||
|
assert.strictEqual(info.chapterCount, 6);
|
||||||
|
assert.strictEqual(info.title, '未命名文本',
|
||||||
|
'首行本身是章节标题时不能拿来当书名,否则书库标题会变成「第1章」');
|
||||||
|
assert.ok(progress.length && progress[progress.length - 1] === 1, 'onProgress 必须走到 1');
|
||||||
|
assert.ok(progress.every((p) => p >= 0 && p <= 1), '进度必须落在 0..1');
|
||||||
|
|
||||||
|
const toc = await adapter.toc();
|
||||||
|
assert.strictEqual(toc.length, 6);
|
||||||
|
assert.deepStrictEqual(toc.map((t) => t.label),
|
||||||
|
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6']);
|
||||||
|
assert.deepStrictEqual(toc.map((t) => t.locator.chapter), [0, 1, 2, 3, 4, 5]);
|
||||||
|
for (const entry of toc) {
|
||||||
|
assert.strictEqual(entry.locator.kind, 'txt', 'locator.kind 必须是 txt,外壳靠它判断书签与笔记');
|
||||||
|
assert.strictEqual(entry.depth, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = adapter.locatorLabel({ kind: 'txt', chapter: 2, offset: 0 });
|
||||||
|
assert.strictEqual(label, '第3章 标题3');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首行是书名时取作标题,是章节标题时不取', async () => {
|
||||||
|
const { splitPlainText } = await mod;
|
||||||
|
assert.strictEqual(splitPlainText(`某本小说的书名\n\n${novel(4)}`).title, '某本小说的书名');
|
||||||
|
assert.strictEqual(splitPlainText(novel(4)).title, '',
|
||||||
|
'首行是「第1章」时留空,交由上层用文件名兜底');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("textOf(locator, 'document') 返回全文,不缺不重", async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const source = novel(8);
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(source));
|
||||||
|
const full = await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||||
|
assert.strictEqual(full, tidy(source),
|
||||||
|
'AI 的「全文」范围完全依赖这条,少一个字就是静默数据丢失');
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
const token = `【标记${String(i).padStart(4, '0')}】`;
|
||||||
|
assert.strictEqual(full.split(token).length - 1, 1, `${token} 必须恰好出现一次`);
|
||||||
|
}
|
||||||
|
const positions = [];
|
||||||
|
for (let i = 1; i <= 8; i++) positions.push(full.indexOf(`【标记${String(i).padStart(4, '0')}】`));
|
||||||
|
assert.deepStrictEqual(positions, [...positions].sort((a, b) => a - b), '全文顺序必须与原文一致');
|
||||||
|
|
||||||
|
const one = await adapter.textOf({ kind: 'txt', chapter: 3, offset: 0 }, 'chapter');
|
||||||
|
assert.ok(one.includes('【标记0004】'), '按章取文必须取到对应章');
|
||||||
|
assert.ok(!one.includes('【标记0005】'), '按章取文不得越界');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Markdown 走完整管线:标题渲染成 h1/h2,代码块与表格保留', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const source = [
|
||||||
|
'# 文档标题', '', '开场白。', '',
|
||||||
|
'## 列表与代码', '',
|
||||||
|
'- 第一项', '- 第二项', '',
|
||||||
|
'```js', 'const x = 1;', '```', '',
|
||||||
|
'> 引用一句话', '',
|
||||||
|
'| 列一 | 列二 |', '| --- | --- |', '| 1 | 2 |', '',
|
||||||
|
'**加粗**与 `行内代码`。', ''
|
||||||
|
].join('\n');
|
||||||
|
const adapter = createTextAdapter('md');
|
||||||
|
try {
|
||||||
|
const info = await adapter.load(u8(source));
|
||||||
|
assert.strictEqual(info.format, 'md');
|
||||||
|
assert.strictEqual(info.mode, 'markdown');
|
||||||
|
assert.strictEqual(info.title, '文档标题');
|
||||||
|
assert.strictEqual(info.chapterCount, 2);
|
||||||
|
|
||||||
|
const toc = await adapter.toc();
|
||||||
|
assert.deepStrictEqual(toc.map((t) => [t.label, t.depth]), [['文档标题', 0], ['列表与代码', 1]]);
|
||||||
|
assert.strictEqual(toc[0].locator.kind, 'md', 'Markdown 的 locator.kind 必须是 md');
|
||||||
|
|
||||||
|
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||||
|
for (const piece of ['文档标题', '开场白。', '第一项', '第二项', 'const x = 1;', '引用一句话', '列一', '加粗', '行内代码']) {
|
||||||
|
assert.ok(full.includes(piece), `全文里必须有「${piece}」,Markdown 渲染不能吞内容`);
|
||||||
|
}
|
||||||
|
assert.ok(!full.includes('```'), 'Markdown 记号应被渲染掉而不是原样留在正文里');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('渲染后的块级元素在全文里彼此分行,AI 不会拿到糊成一团的文本', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('md');
|
||||||
|
try {
|
||||||
|
// 标题与紧随其后的正文之间原文没有空行,最容易被拼成「标题甲正文乙」
|
||||||
|
await adapter.load(u8('# 标题甲\n正文乙\n\n## 标题丙\n正文丁\n\n- 列表戊\n- 列表己\n'));
|
||||||
|
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||||
|
for (const [a, b] of [['标题甲', '正文乙'], ['标题丙', '正文丁'], ['列表戊', '列表己']]) {
|
||||||
|
assert.ok(!full.includes(a + b),
|
||||||
|
`「${a}」与「${b}」被拼成了一个词,块级元素之间必须留分隔符,否则送给模型的全文语义错乱`);
|
||||||
|
assert.ok(new RegExp(`${a}\\n+${b}`).test(full), `「${a}」与「${b}」之间应有换行`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Markdown 章节渲染成真正的块级结构,不是纯文本', async () => {
|
||||||
|
const { buildTextEpub } = await mod;
|
||||||
|
const built = await buildTextEpub(u8([
|
||||||
|
'# 标题', '', '- 项', '', '```js', 'const x = 1;', '```', '',
|
||||||
|
'> 引用', '', '| a | b |', '| - | - |', '| 1 | 2 |', ''
|
||||||
|
].join('\n')), { format: 'md' });
|
||||||
|
const doc = parseXhtml(await readChapter(built.bytes, 0));
|
||||||
|
assert.strictEqual(doc.querySelectorAll('h1').length, 1, 'Markdown 标题必须渲染成 h1');
|
||||||
|
assert.strictEqual(doc.querySelectorAll('ul > li').length, 1);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('pre > code').length, 1);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('blockquote').length, 1);
|
||||||
|
assert.strictEqual(doc.querySelectorAll('table td').length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超大 Markdown 降级为纯文本,不让 markdown-it 整篇解析', async () => {
|
||||||
|
const { buildTextEpub, MARKDOWN_MAX_CHARS } = await mod;
|
||||||
|
const over = `# 标题\n\n${'abcde\n'.repeat(Math.ceil(MARKDOWN_MAX_CHARS / 6) + 100)}`;
|
||||||
|
assert.ok(over.length > MARKDOWN_MAX_CHARS);
|
||||||
|
const built = await buildTextEpub(u8(over), { format: 'md' });
|
||||||
|
assert.strictEqual(built.mode, 'plain', '超限时必须降级,否则整篇解析的开销与内存都不可控');
|
||||||
|
const under = await buildTextEpub(u8('# 标题\n\n正文。\n'), { format: 'md' });
|
||||||
|
assert.strictEqual(under.mode, 'markdown', '正常体量的 md 必须走渲染路径');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('几十 MB 的 txt 切成小章,单章体积可控', async () => {
|
||||||
|
const { buildTextEpub, splitPlainText } = await mod;
|
||||||
|
const block = `第N章 标题\n${'这一段是压力测试用的中文正文。'.repeat(10)}\n\n`;
|
||||||
|
const source = block.repeat(2000).replace(/第N章/g, () => '第1章');
|
||||||
|
const split = splitPlainText(source);
|
||||||
|
assert.ok(split.chapters.length <= 4000, '章节数必须有上限,否则 zip 条目数失控');
|
||||||
|
assert.ok(split.chapters.every((c) => c.text.length <= 48000),
|
||||||
|
'单章必须足够小,一次性把整份文本塞进 DOM 会卡死渲染进程');
|
||||||
|
const built = await buildTextEpub(u8(source), { format: 'txt' });
|
||||||
|
assert.strictEqual(built.chapterCount, split.chapters.length);
|
||||||
|
assert.strictEqual(built.mode, 'plain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextLocator / prevLocator 在两端返回 null', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(novel(4)));
|
||||||
|
assert.strictEqual(adapter.prevLocator({ kind: 'txt', chapter: 0, offset: 0 }), null,
|
||||||
|
'第一章没有上一章,返回 null 外壳才会禁用按钮');
|
||||||
|
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 3, offset: 0 }), null,
|
||||||
|
'最后一章没有下一章');
|
||||||
|
const next = adapter.nextLocator({ kind: 'txt', chapter: 0, offset: 500 });
|
||||||
|
assert.deepStrictEqual(next, { kind: 'txt', chapter: 1, offset: 0 });
|
||||||
|
const prev = adapter.prevLocator({ kind: 'txt', chapter: 2, offset: 10 });
|
||||||
|
assert.deepStrictEqual(prev, { kind: 'txt', chapter: 1, offset: 0 });
|
||||||
|
assert.deepStrictEqual(adapter.nextLocator(null), { kind: 'txt', chapter: 1, offset: 0 },
|
||||||
|
'非法 locator 要按第 0 章处理,不能抛');
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('percentOf 与 locatorFromPercent 互为逆运算', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(novel(8)));
|
||||||
|
// 章内百分比需要章节长度,先取一次全文把长度缓存起来(外壳打开书后同样会发生)
|
||||||
|
await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||||
|
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 0, offset: 0 }), 0);
|
||||||
|
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 7, offset: 1e9 }), 1);
|
||||||
|
for (const p of [0, 0.125, 0.3, 0.5, 0.77, 1]) {
|
||||||
|
const locator = adapter.locatorFromPercent(p);
|
||||||
|
assert.strictEqual(locator.kind, 'txt');
|
||||||
|
const back = adapter.percentOf(locator);
|
||||||
|
assert.ok(Math.abs(back - p) < 0.02,
|
||||||
|
`百分比往返偏差过大:${p} -> ${JSON.stringify(locator)} -> ${back};进度条与书签会漂移`);
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(adapter.locatorFromPercent(-5), { kind: 'txt', chapter: 0, offset: 0 },
|
||||||
|
'越界百分比必须夹紧');
|
||||||
|
assert.strictEqual(adapter.locatorFromPercent(99).chapter, 7);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超过体积上限时 load 抛中文错误', async () => {
|
||||||
|
const { createTextAdapter, MAX_TEXT_BYTES } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => adapter.load(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||||
|
/超过 64 MB/,
|
||||||
|
'超限错误会原样显示给用户,必须是中文'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('适配器暴露外壳依赖的全部方法,且 .md 后缀归一', async () => {
|
||||||
|
const { createTextAdapter, normalizeTextFormat } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
for (const name of [
|
||||||
|
'load', 'renderTo', 'toc', 'getSelection', 'textOf', 'locatorLabel',
|
||||||
|
'nextLocator', 'prevLocator', 'percentOf', 'locatorFromPercent',
|
||||||
|
'capturePinchAnchor', 'restorePinchAnchor', 'setLocatorChangeHandler',
|
||||||
|
'setTouchGestureHandler', 'visualViewportRect', 'destroy'
|
||||||
|
]) {
|
||||||
|
assert.strictEqual(typeof adapter[name], 'function', `外壳会调用 ${name},必须实现`);
|
||||||
|
}
|
||||||
|
assert.strictEqual(adapter.getSelection(), null, '未渲染时取选区应返回 null 而不是抛错');
|
||||||
|
assert.strictEqual(adapter.visualViewportRect(), null);
|
||||||
|
assert.strictEqual(adapter.capturePinchAnchor(0, 0), null);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
assert.strictEqual(normalizeTextFormat('.MD'), 'md');
|
||||||
|
assert.strictEqual(normalizeTextFormat('markdown'), 'md');
|
||||||
|
assert.strictEqual(normalizeTextFormat('txt'), 'txt');
|
||||||
|
assert.strictEqual(normalizeTextFormat(undefined), 'txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 之后再次 load 不残留上一本书的状态', async () => {
|
||||||
|
const { createTextAdapter } = await mod;
|
||||||
|
const adapter = createTextAdapter('txt');
|
||||||
|
try {
|
||||||
|
await adapter.load(u8(novel(6)));
|
||||||
|
assert.strictEqual((await adapter.toc()).length, 6);
|
||||||
|
adapter.destroy();
|
||||||
|
assert.strictEqual(adapter.documentInfo(), null, 'destroy 必须清掉文档信息');
|
||||||
|
await adapter.load(u8(novel(3)));
|
||||||
|
assert.strictEqual((await adapter.toc()).length, 3, '重新 load 后目录必须是新书的');
|
||||||
|
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 2, offset: 0 }), null);
|
||||||
|
} finally {
|
||||||
|
adapter.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -165,9 +165,32 @@ test('书库页提供可管理标签目录和整理多选下拉', () => {
|
|||||||
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
|
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('下载区展示进度且完成按钮使用高对比绿色底色', () => {
|
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 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');
|
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, /createDownloadProgress/);
|
||||||
assert.match(browse, /updateDownloadProgress/);
|
assert.match(browse, /updateDownloadProgress/);
|
||||||
assert.match(browse, /classList\.add\('downloaded'\)/);
|
assert.match(browse, /classList\.add\('downloaded'\)/);
|
||||||
@@ -307,8 +330,14 @@ test('AI 上下文提供无需选中的当前页与全文范围', () => {
|
|||||||
|
|
||||||
// 全文必须提示可能超限,并且始终弹确认框
|
// 全文必须提示可能超限,并且始终弹确认框
|
||||||
assert.match(shell, /可能超过模型限制/);
|
assert.match(shell, /可能超过模型限制/);
|
||||||
assert.match(shell, /全文可能超过模型的上下文限制/);
|
|
||||||
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
|
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
|
// 旧设置迁移,避免升级后回落成 selection
|
||||||
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
|
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
|
||||||
@@ -374,12 +403,13 @@ test('设置关于页与 README 列出书库和内置阅读格式', () => {
|
|||||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
|
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
|
||||||
assert.match(html, /关于 PeopleLib/);
|
assert.match(html, /关于 PeopleLib/);
|
||||||
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3/);
|
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3、TXT、MD/);
|
||||||
assert.match(html, /书库导入与管理[\s\S]*TXT、DJVU、FB2、CBZ、CBR/);
|
assert.match(html, /书库导入与管理[\s\S]*TXT、MD、DJVU、FB2、CBZ、CBR/);
|
||||||
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
|
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
|
||||||
assert.match(readme, /## 支持格式/);
|
assert.match(readme, /## 支持格式/);
|
||||||
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
|
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
|
||||||
assert.match(readme, /TXT \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
|
assert.match(readme, /TXT \/ MD[\s\S]*Markdown/);
|
||||||
|
assert.match(readme, /DJVU \/ FB2 \/ CBZ \/ CBR/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
|
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
|
||||||
@@ -389,7 +419,7 @@ test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器
|
|||||||
const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.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');
|
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||||
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
|
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
|
||||||
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/);
|
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/);
|
||||||
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
|
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
|
||||||
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
|
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
|
||||||
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
|
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
|
||||||
@@ -495,12 +525,241 @@ test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版
|
|||||||
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
|
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('书架操作对键盘焦点可见', () => {
|
test('书架操作对键盘焦点可见', () => {
|
||||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
|
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
|
||||||
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*none/);
|
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('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
||||||
const library = fs.readFileSync(libFile, 'utf8');
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||||
@@ -512,6 +771,213 @@ test('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
|||||||
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
|
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AI 面板以线程容器承载多轮对话气泡', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||||
|
|
||||||
|
// 结构约定要写在 HTML 里,集成测试与 CSS 都按 ai-thread / ai-msg 定位
|
||||||
|
assert.match(html, /id="aiOutput" class="ai-output ai-thread"/);
|
||||||
|
assert.match(shell, /box\.className = `ai-msg ai-msg-\$\{role\}`/);
|
||||||
|
assert.match(shell, /box\.dataset\.messageId = String\(message\.id \|\| ''\)/);
|
||||||
|
assert.match(shell, /body\.className = 'ai-msg-body'/);
|
||||||
|
assert.match(shell, /meta\.className = 'ai-msg-meta'/);
|
||||||
|
assert.match(css, /\.ai-msg-body\s*\{/);
|
||||||
|
assert.match(css, /\.ai-thread\s*\{[^}]*flex-direction:\s*column/);
|
||||||
|
|
||||||
|
// 新回答必须追加而不是覆盖:整块重写等于回到一问一答
|
||||||
|
assert.match(shell, /function appendAiMessage\(message\)/);
|
||||||
|
assert.match(shell, /el\.aiOutput\.appendChild\(node\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 流式增量只重渲染正在生成的那一条气泡', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
// 线程里有几十条消息时,每 80ms 重解析全部 Markdown 会把界面拖死,
|
||||||
|
// 因此增量渲染只允许写 aiStreamNode 里的 .ai-msg-body
|
||||||
|
const render = shell.match(/function renderAiOutput\([\s\S]*?\n\}/);
|
||||||
|
assert.ok(render, '缺少 renderAiOutput');
|
||||||
|
assert.match(render[0], /aiStreamNode\.querySelector\('\.ai-msg-body'\)/);
|
||||||
|
assert.match(render[0], /renderMessageBody\(body,/);
|
||||||
|
assert.doesNotMatch(render[0], /AiMarkdown\.mount\(el\.aiOutput/);
|
||||||
|
assert.doesNotMatch(render[0], /renderAiThread\(\)/);
|
||||||
|
// 整篇重建只发生在换会话时,不能出现在节流的增量路径上
|
||||||
|
const schedule = shell.match(/function scheduleAiOutput\([\s\S]*?\n\}/);
|
||||||
|
assert.ok(schedule, '缺少 scheduleAiOutput');
|
||||||
|
assert.doesNotMatch(schedule[0], /renderAiThread\(\)/);
|
||||||
|
assert.doesNotMatch(schedule[0], /AiMarkdown\.mount\(el\.aiOutput/);
|
||||||
|
|
||||||
|
// 80ms 节流与「贴底才自动滚动」都要保留
|
||||||
|
assert.match(shell, /aiRenderTimer = window\.setTimeout\([\s\S]{0,120}\}, 80\)/);
|
||||||
|
assert.match(shell, /el\.aiOutput\.scrollHeight - el\.aiOutput\.scrollTop - el\.aiOutput\.clientHeight < 48/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 增量按 messageId 路由到对应气泡', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
const delta = shell.match(/unsubDelta = api\.ai\.onDelta\([\s\S]*?\n \}\);/);
|
||||||
|
assert.ok(delta, '缺少 onDelta 订阅');
|
||||||
|
// 只按 runId 过滤会让同一次运行里的旧气泡也收到增量,串成一团
|
||||||
|
assert.match(delta[0], /const messageId = d\.messageId \? String\(d\.messageId\) : ''/);
|
||||||
|
assert.match(delta[0], /messageId !== aiRun\.messageId\) return/);
|
||||||
|
assert.match(shell, /function adoptStreamingMessageId\(messageId\)/);
|
||||||
|
assert.match(shell, /aiRun\.messageId = String\(messageId\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话管理提供新建、重命名、置顶、清空与删除', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /id="aiSessionSelect"[^>]+title="切换当前书籍的对话会话"/);
|
||||||
|
assert.match(html, /id="aiSessionNewBtn"[^>]*>新建</);
|
||||||
|
assert.match(html, /id="aiSessionRenameBtn"[^>]*>重命名</);
|
||||||
|
assert.match(html, /id="aiSessionPinBtn"[^>]*>置顶</);
|
||||||
|
assert.match(html, /id="aiSessionClearBtn"[^>]*>清空</);
|
||||||
|
assert.match(html, /id="aiSessionDeleteBtn"[^>]*>删除</);
|
||||||
|
|
||||||
|
// 会话下拉按 pinned 优先、updatedAt 倒序,标题为空时显示「新会话」
|
||||||
|
assert.match(shell, /a\.pinned \? -1 : 1/);
|
||||||
|
assert.match(shell, /Number\(b\.updatedAt\) \|\| 0\) - \(Number\(a\.updatedAt\) \|\| 0\)/);
|
||||||
|
assert.match(shell, /return title \|\| '新会话'/);
|
||||||
|
|
||||||
|
// 一开书就建空会话会很快占满 100 个上限,必须延迟到首次提问
|
||||||
|
assert.match(shell, /async function ensureAiSession\(\)/);
|
||||||
|
assert.match(shell, /if \(aiSessionId\) return aiSessionId/);
|
||||||
|
const activateBlock = shell.match(/async function activate\(id\)[\s\S]*?\n\}/);
|
||||||
|
assert.ok(activateBlock);
|
||||||
|
assert.match(activateBlock[0], /refreshAiSessions\(\)/);
|
||||||
|
assert.doesNotMatch(activateBlock[0], /sessions\.create\(/);
|
||||||
|
|
||||||
|
// 生成中禁止切会话、删会话与发新问题
|
||||||
|
assert.match(shell, /el\.aiSessionSelect\.disabled = busy \|\| !hasEntry/);
|
||||||
|
assert.match(shell, /el\.aiSessionDeleteBtn\.disabled = busy \|\| !hasSession/);
|
||||||
|
assert.match(shell, /function aiBusy\(busy\)[\s\S]{0,400}syncAiSessionControls\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('删除与清空 AI 会话都要走应用内二次确认', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /id="aiSessionDeleteModal"[\s\S]*id="aiSessionDeleteConfirmBtn"[^>]*>删除会话</);
|
||||||
|
assert.match(html, /id="aiSessionClearModal"[\s\S]*id="aiSessionClearConfirmBtn"[^>]*>清空消息</);
|
||||||
|
assert.match(html, /id="aiSessionRenameModal"[\s\S]*id="aiSessionTitleInput"/);
|
||||||
|
|
||||||
|
// 对话记录删掉找不回来,必须先确认再调 remove/clear
|
||||||
|
const remove = shell.match(/async function deleteAiSession\(\)[\s\S]*?\n\}/);
|
||||||
|
assert.ok(remove, '缺少 deleteAiSession');
|
||||||
|
const removeConfirmAt = remove[0].indexOf('await confirmAiSessionDelete(');
|
||||||
|
const removeCallAt = remove[0].indexOf('sessions.remove(');
|
||||||
|
assert.ok(removeConfirmAt >= 0, '删除会话缺少二次确认,会一键抹掉全部对话记录');
|
||||||
|
assert.ok(removeCallAt >= 0, '删除会话未调用 sessions.remove');
|
||||||
|
assert.ok(removeConfirmAt < removeCallAt, '删除会话必须先弹二次确认再调 sessions.remove');
|
||||||
|
const clear = shell.match(/async function clearAiSession\(\)[\s\S]*?\n\}/);
|
||||||
|
assert.ok(clear, '缺少 clearAiSession');
|
||||||
|
const clearConfirmAt = clear[0].indexOf('await confirmAiSessionClear(');
|
||||||
|
const clearCallAt = clear[0].indexOf('sessions.clear(');
|
||||||
|
assert.ok(clearConfirmAt >= 0, '清空会话缺少二次确认,消息删掉找不回来');
|
||||||
|
assert.ok(clearCallAt >= 0, '清空会话未调用 sessions.clear');
|
||||||
|
assert.ok(clearConfirmAt < clearCallAt, '清空会话必须先弹二次确认再调 sessions.clear');
|
||||||
|
assert.match(shell, /function confirmAiSessionDelete\(title\)[\s\S]{0,400}aiSessionDeleteModal\.classList\.remove\('hidden'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话调用 api.ai.sessions 契约并带上 sessionId 发送', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /api\.ai && api\.ai\.sessions \? api\.ai\.sessions : null/);
|
||||||
|
assert.match(shell, /sessions\.list\(\{ entryId \}\)/);
|
||||||
|
assert.match(shell, /sessions\.create\(\{ entryId, title: '', documentKey: tab\.documentKey \|\| null \}\)/);
|
||||||
|
assert.match(shell, /sessions\.rename\(aiSessionId, title\)/);
|
||||||
|
assert.match(shell, /sessions\.setPinned\(aiSessionId, next\)/);
|
||||||
|
assert.match(shell, /sessions\.remove\(removed\)/);
|
||||||
|
assert.match(shell, /sessions\.clear\(aiSessionId\)/);
|
||||||
|
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_THREAD_LIMIT \}\)/);
|
||||||
|
// sessionId 不下发就退化成不落盘的一次性提问,线程无法续接
|
||||||
|
assert.match(shell, /api\.ai\.run\(\{\s*\n\s*runId,\s*\n\s*sessionId,/);
|
||||||
|
assert.match(shell, /res\.data\.assistantMessageId|ids\.assistantMessageId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 每条回答各自提供保存此回答与复制', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /save\.className = 'tb-btn sm ai-msg-save'/);
|
||||||
|
assert.match(shell, /save\.textContent = '保存此回答'/);
|
||||||
|
assert.match(shell, /copy\.className = 'tb-btn ghost sm ai-msg-copy'/);
|
||||||
|
assert.match(shell, /saveAiNote\(aiResultOf\(box\.dataset\.messageId\)\)/);
|
||||||
|
assert.match(shell, /copyAiMessage\(box\.dataset\.messageId\)/);
|
||||||
|
// locator 取该条对应 user 消息的 contextRef,quote 取那条问题文本
|
||||||
|
assert.match(shell, /if \(aiThread\[i\]\.role === 'user'\) \{ ask = aiThread\[i\]; break; \}/);
|
||||||
|
assert.match(shell, /locator: \(ref && ref\.locator\) \|\| null/);
|
||||||
|
assert.match(shell, /quote: ask \? String\(ask\.text \|\| ''\) : ''/);
|
||||||
|
assert.match(shell, /async function saveAiNote\(target\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话可按最近轮数或全部保留消息保存为一条笔记', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /id="aiSaveBtn"[^>]+title="把当前会话保存为一条笔记"[^>]*>保存会话…</);
|
||||||
|
assert.match(html, /id="aiSessionSaveModal"[\s\S]*id="aiSessionSaveRecent"/);
|
||||||
|
assert.match(html, /id="aiSessionSaveRounds"[^>]+type="number"[^>]+min="1"[^>]+max="100"/);
|
||||||
|
assert.match(html, /id="aiSessionSaveAll"[\s\S]*当前保留会话全部/);
|
||||||
|
assert.match(html, /id="aiSessionSaveRoundCount"[\s\S]*id="aiSessionSaveCharCount"/);
|
||||||
|
|
||||||
|
// 保存时必须另取后端当前保留的全部 200 条,不能复用界面当前 60 条
|
||||||
|
assert.match(shell, /const AI_THREAD_LIMIT = 60/);
|
||||||
|
assert.match(shell, /const AI_SESSION_SAVE_MESSAGE_LIMIT = 200/);
|
||||||
|
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_SESSION_SAVE_MESSAGE_LIMIT \}\)/);
|
||||||
|
assert.match(shell, /function aiConversationRounds\(messages\)/);
|
||||||
|
assert.match(shell, /message\.role === 'user'[\s\S]{0,220}message\.role === 'assistant' && user/);
|
||||||
|
assert.match(shell, /return all\.slice\(-count\)/);
|
||||||
|
|
||||||
|
// 范围和 N 记住上次选择,确认前展示真实轮数和最终正文字符数
|
||||||
|
assert.match(shell, /const AI_SESSION_SAVE_SETTING = 'reader\.aiSessionSave'/);
|
||||||
|
assert.match(shell, /api\.settings\.get\(AI_SESSION_SAVE_SETTING, aiSessionSavePreference\)/);
|
||||||
|
assert.match(shell, /api\.settings\.set\(AI_SESSION_SAVE_SETTING, preference\)/);
|
||||||
|
assert.match(shell, /aiSessionSaveRoundCount\.textContent = `\$\{selected\.length\.toLocaleString\(\)\} 轮`/);
|
||||||
|
assert.match(shell, /aiSessionSaveCharCount\.textContent = `\$\{text\.length\.toLocaleString\(\)\} 字`/);
|
||||||
|
|
||||||
|
const saveStart = shell.indexOf('async function saveAiSessionNote()');
|
||||||
|
const saveEnd = shell.indexOf('\nasync function renameAiSession()', saveStart);
|
||||||
|
assert.ok(saveStart >= 0 && saveEnd > saveStart, '缺少保存会话实现');
|
||||||
|
const saveBlock = shell.slice(saveStart, saveEnd);
|
||||||
|
assert.strictEqual((saveBlock.match(/api\.reader\.addNote\(/g) || []).length, 1, '一次会话保存只能调用一次 addNote');
|
||||||
|
assert.match(saveBlock, /title: state\.title/);
|
||||||
|
assert.match(saveBlock, /\n\s+text,/);
|
||||||
|
assert.match(saveBlock, /quote: ''/);
|
||||||
|
assert.match(saveBlock, /locator: null/);
|
||||||
|
assert.match(saveBlock, /已将 \$\{selected\.length\.toLocaleString\(\)\} 轮会话(\$\{text\.length\.toLocaleString\(\)\} 字)保存为一条笔记/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 会话笔记正文标明问答结构与未保留的较早消息', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /`## 第 \$\{index \+ 1\} 轮\\n\\n### 问题\\n\\n\$\{question\}\\n\\n### 回答\\n\\n\$\{answer\}`/);
|
||||||
|
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不在当前保留会话中,因此未保存/);
|
||||||
|
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已被会话存储上限淘汰,不会出现在笔记中/);
|
||||||
|
assert.match(shell, /会话正文将完整写入一条笔记,不会拆分,也不会在此处截断/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
assert.match(shell, /const parts = \[scopeName\(ref\.scope\)\]/);
|
||||||
|
assert.match(shell, /parts\.push\(`\$\{chars\.toLocaleString\(\)\} 字`\)/);
|
||||||
|
assert.match(shell, /`上下文:\$\{parts\.join\(' · '\)\}`/);
|
||||||
|
assert.match(shell, /if \(message\.truncated\) flags\.push\('内容已裁剪'\)/);
|
||||||
|
assert.match(shell, /if \(message\.cancelled\) flags\.push\('已停止生成'\)/);
|
||||||
|
assert.match(shell, /box\.classList\.add\('ai-msg-error'\)/);
|
||||||
|
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不再保留/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 线程里的模型输出全部经 AiMarkdown 渲染,不直接写 innerHTML', () => {
|
||||||
|
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||||
|
// 渲染层 CSP 挡不住 DOM 注入,模型文本必须过 AiMarkdown 内的 DOMPurify
|
||||||
|
assert.match(shell, /function renderMessageBody\(body, source\)[\s\S]{0,300}window\.AiMarkdown\.mount\(body, source\)/);
|
||||||
|
assert.match(shell, /else renderMessageBody\(body, message\.text\)/);
|
||||||
|
assert.doesNotMatch(shell, /\.innerHTML\s*=/);
|
||||||
|
// 用户输入按纯文本落地,同样不经 HTML 解析
|
||||||
|
assert.match(shell, /if \(role === 'user'\) body\.textContent = String\(message\.text \|\| ''\)/);
|
||||||
|
// 链接拦截仍挂在整个线程容器上,新增气泡里的链接不会漏掉
|
||||||
|
assert.match(shell, /el\.aiOutput\.addEventListener\('click', activateAiLink\)/);
|
||||||
|
assert.match(shell, /el\.aiOutput\.addEventListener\('auxclick', activateAiLink\)/);
|
||||||
|
assert.match(shell, /if \(!link \|\| !el\.aiOutput\.contains\(link\)\) return/);
|
||||||
|
});
|
||||||
|
|
||||||
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
|
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
|
||||||
const library = fs.readFileSync(libFile, 'utf8');
|
const library = fs.readFileSync(libFile, 'utf8');
|
||||||
assert.match(library, /data-act="read" role="button" tabindex="0"/);
|
assert.match(library, /data-act="read" role="button" tabindex="0"/);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -8,6 +8,7 @@ const BOOK_EXT = new Set([
|
|||||||
'azw',
|
'azw',
|
||||||
'azw3',
|
'azw3',
|
||||||
'txt',
|
'txt',
|
||||||
|
'md',
|
||||||
'djvu',
|
'djvu',
|
||||||
'fb2',
|
'fb2',
|
||||||
'cbz',
|
'cbz',
|
||||||
|
|||||||
@@ -13,14 +13,13 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { fetchWithProxy } = require('../sources/http');
|
const atomic = require('../atomic-file');
|
||||||
|
const { UA: DL_UA, fetchWithProxy } = require('../sources/http');
|
||||||
const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
|
||||||
|
|
||||||
const SCHEMA_VERSION = 4;
|
const SCHEMA_VERSION = 4;
|
||||||
const MAX_TAGS = 50;
|
const MAX_TAGS = 50;
|
||||||
const MAX_TAG_LENGTH = 64;
|
const MAX_TAG_LENGTH = 64;
|
||||||
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr']);
|
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr']);
|
||||||
|
|
||||||
let rootDir = null;
|
let rootDir = null;
|
||||||
let items = null;
|
let items = null;
|
||||||
@@ -129,36 +128,14 @@ function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persistTo(dir, value, shelfValue = shelves || [], tagValue = tags || []) {
|
function persistTo(dir, value, shelfValue = shelves || [], tagValue = tags || []) {
|
||||||
const dest = path.join(dir, 'library.json');
|
|
||||||
const temp = `${dest}.tmp`;
|
|
||||||
const backup = `${dest}.bak`;
|
|
||||||
let backedUp = false;
|
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
atomic.writeJson(path.join(dir, 'library.json'), {
|
||||||
fs.writeFileSync(
|
version: SCHEMA_VERSION,
|
||||||
temp,
|
shelves: shelfValue,
|
||||||
JSON.stringify({
|
tags: tagValue,
|
||||||
version: SCHEMA_VERSION,
|
items: value
|
||||||
shelves: shelfValue,
|
});
|
||||||
tags: tagValue,
|
|
||||||
items: value
|
|
||||||
}, null, 2),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
fs.renameSync(dest, backup);
|
|
||||||
backedUp = true;
|
|
||||||
}
|
|
||||||
fs.renameSync(temp, dest);
|
|
||||||
if (backedUp) {
|
|
||||||
try { fs.unlinkSync(backup); } catch (cleanupError) { /* 保留备份不影响提交 */ }
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
|
|
||||||
try {
|
|
||||||
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
|
||||||
} catch (rollbackError) { /* 下次加载时会从 .bak 恢复 */ }
|
|
||||||
throw new Error(`书库索引写入失败: ${e.message || e}`);
|
throw new Error(`书库索引写入失败: ${e.message || e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -788,11 +765,7 @@ function update(id, patch) {
|
|||||||
load();
|
load();
|
||||||
const it = items.find((x) => x.id === id);
|
const it = items.find((x) => x.id === id);
|
||||||
if (!it) throw new Error('条目不存在');
|
if (!it) throw new Error('条目不存在');
|
||||||
const next = { ...patch };
|
const next = normalizedPatch(patch);
|
||||||
if (next.files) next.files = next.files.map(normalizeFile);
|
|
||||||
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
|
|
||||||
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
|
|
||||||
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) next.shelfId = normalizeShelfId(next.shelfId);
|
|
||||||
const updated = { ...it, ...next, updatedAt: Date.now() };
|
const updated = { ...it, ...next, updatedAt: Date.now() };
|
||||||
const nextItems = items.map((x) => x.id === id ? updated : x);
|
const nextItems = items.map((x) => x.id === id ? updated : x);
|
||||||
const organizationChanged = Object.prototype.hasOwnProperty.call(next, 'tags')
|
const organizationChanged = Object.prototype.hasOwnProperty.call(next, 'tags')
|
||||||
@@ -929,6 +902,82 @@ function attachFile(id, filePath) {
|
|||||||
return expand(updated);
|
return expand(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedPatch(patch) {
|
||||||
|
const next = { ...patch };
|
||||||
|
if (next.files) next.files = next.files.map(normalizeFile);
|
||||||
|
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
|
||||||
|
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) {
|
||||||
|
next.shelfId = normalizeShelfId(next.shelfId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量整理走单次 commit:逐条 update 会把整个索引重写 N 遍,
|
||||||
|
// 几千条的书库里批量改动会卡住界面
|
||||||
|
function updateMany(patches) {
|
||||||
|
load();
|
||||||
|
if (!Array.isArray(patches) || !patches.length) return { updated: 0 };
|
||||||
|
const byId = new Map();
|
||||||
|
for (const entry of patches) {
|
||||||
|
if (!entry || entry.id == null) throw new Error('批量更新缺少条目 ID');
|
||||||
|
const id = String(entry.id);
|
||||||
|
if (!items.some((x) => x.id === id)) throw new Error('条目不存在');
|
||||||
|
byId.set(id, normalizedPatch(entry.patch || {}));
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
let updated = 0;
|
||||||
|
const nextItems = items.map((x) => {
|
||||||
|
const patch = byId.get(x.id);
|
||||||
|
if (!patch) return x;
|
||||||
|
updated++;
|
||||||
|
return { ...x, ...patch, updatedAt: now };
|
||||||
|
});
|
||||||
|
commit(nextItems, true);
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMany(ids, deleteFiles) {
|
||||||
|
load();
|
||||||
|
if (!Array.isArray(ids) || !ids.length) return { removed: 0 };
|
||||||
|
const targets = ids.map((id) => String(id));
|
||||||
|
const unique = new Set(targets);
|
||||||
|
const doomed = items.filter((x) => unique.has(x.id));
|
||||||
|
const nextItems = items.filter((x) => !unique.has(x.id));
|
||||||
|
const staged = [];
|
||||||
|
if (deleteFiles) {
|
||||||
|
try {
|
||||||
|
for (const it of doomed) {
|
||||||
|
for (const f of it.files || []) {
|
||||||
|
const abs = toAbsolute(f.path);
|
||||||
|
if (!abs || !isWithin(rootDir, abs) || !fs.existsSync(abs)) continue;
|
||||||
|
const temp = `${abs}.deleting-${genId()}`;
|
||||||
|
fs.renameSync(abs, temp);
|
||||||
|
staged.push({ abs, temp });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
for (const f of staged.reverse()) {
|
||||||
|
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
commit(nextItems, true);
|
||||||
|
} catch (e) {
|
||||||
|
for (const f of staged.reverse()) {
|
||||||
|
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
for (const f of staged) {
|
||||||
|
try { fs.unlinkSync(f.temp); } catch (e) { /* 文件已移出书库,稍后可手动清理 */ }
|
||||||
|
}
|
||||||
|
for (const it of doomed) removeCoverFile(it.id);
|
||||||
|
return { removed: doomed.length };
|
||||||
|
}
|
||||||
|
|
||||||
function remove(id, deleteFiles) {
|
function remove(id, deleteFiles) {
|
||||||
load();
|
load();
|
||||||
const it = items.find((x) => x.id === id);
|
const it = items.find((x) => x.id === id);
|
||||||
@@ -1262,7 +1311,8 @@ function importLegacy(legacyDir) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
init, getRoot, filesDir, allocFilePath, sanitize,
|
init, getRoot, filesDir, allocFilePath, sanitize,
|
||||||
list, get, findBySource, listShelves, listTags,
|
list, get, findBySource, listShelves, listTags,
|
||||||
add, importLocal, update, remove, attachFile, addShelf, updateShelf, removeShelf,
|
add, importLocal, update, updateMany, remove, removeMany, attachFile,
|
||||||
|
addShelf, updateShelf, removeShelf,
|
||||||
addTag, updateTag, removeTag,
|
addTag, updateTag, removeTag,
|
||||||
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
|
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
|
||||||
ensureCoverCached, setGeneratedCover, setChangeListener
|
ensureCoverCached, setGeneratedCover, setChangeListener
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const { fetchWithProxy } = require('../sources/http');
|
|||||||
const MAX_CHARS = 12000;
|
const MAX_CHARS = 12000;
|
||||||
const MAX_QUESTION_CHARS = 4000;
|
const MAX_QUESTION_CHARS = 4000;
|
||||||
|
|
||||||
// 上下文按字符数截断。中间挖空而不是尾部截断:
|
// 中间挖空而不是尾部截断:结论性内容常在末尾,只留开头会让模型答非所问。
|
||||||
// 结论性内容常在末尾,只留开头会让模型答非所问。
|
// 正文默认不再走这里,只在有明确预算上限的场合(如多轮历史)显式调用。
|
||||||
function clipContext(text, limit = MAX_CHARS) {
|
function clipContext(text, limit = MAX_CHARS) {
|
||||||
const s = String(text || '');
|
const s = String(text || '');
|
||||||
if (s.length <= limit) return s;
|
if (s.length <= limit) return s;
|
||||||
@@ -42,7 +42,7 @@ const TASKS = {
|
|||||||
function buildPromptFromNormalized(task, text, question, visuals) {
|
function buildPromptFromNormalized(task, text, question, visuals) {
|
||||||
const t = TASKS[task];
|
const t = TASKS[task];
|
||||||
if (!t) throw new Error('不支持的任务类型: ' + task);
|
if (!t) throw new Error('不支持的任务类型: ' + task);
|
||||||
let body = clipContext(text);
|
let body = String(text || '');
|
||||||
const ocr = visuals
|
const ocr = visuals
|
||||||
.filter((item) => item.ocr.include)
|
.filter((item) => item.ocr.include)
|
||||||
.map((item) => item.ocr.text.trim())
|
.map((item) => item.ocr.text.trim())
|
||||||
@@ -58,27 +58,60 @@ function buildPromptFromNormalized(task, text, question, visuals) {
|
|||||||
return { system, userText, images };
|
return { system, userText, images };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessagesFromNormalized(task, text, question, visuals) {
|
// 历史轮只取 role 与 text,其余字段(尤其 images)一律忽略:
|
||||||
|
// 视觉模型按图块计费,重发历史图会让长会话费用随轮数累积,用户无从预期。
|
||||||
|
function normalizeHistory(history) {
|
||||||
|
if (!Array.isArray(history)) return [];
|
||||||
|
const items = [];
|
||||||
|
for (const entry of history) {
|
||||||
|
if (!entry) continue;
|
||||||
|
const role = entry.role === 'assistant' ? 'assistant' : 'user';
|
||||||
|
const text = String(entry.text || '').trim();
|
||||||
|
if (!text) continue;
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
// 相邻同角色合并而不是丢弃:Anthropic 会直接 400,但丢内容比合并更糟
|
||||||
|
if (last && last.role === role) last.text = `${last.text}\n\n${text}`;
|
||||||
|
else items.push({ role, text });
|
||||||
|
}
|
||||||
|
// Anthropic 的 /messages 要求首条必须是 user
|
||||||
|
while (items.length && items[0].role === 'assistant') items.shift();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前轮固定是 user,历史末条若也是 user 就会相邻同角色,把它并入当前轮正文。
|
||||||
|
function mergeHistory(history, currentText) {
|
||||||
|
const items = normalizeHistory(history);
|
||||||
|
const tail = items.length && items[items.length - 1].role === 'user' ? items.pop() : null;
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
currentText: tail ? `${tail.text}\n\n${currentText}` : currentText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessagesFromNormalized(task, text, question, visuals, history) {
|
||||||
const { system, userText, images } = buildPromptFromNormalized(task, text, question, visuals);
|
const { system, userText, images } = buildPromptFromNormalized(task, text, question, visuals);
|
||||||
|
const merged = mergeHistory(history, userText);
|
||||||
const userContent = images.length
|
const userContent = images.length
|
||||||
? [
|
? [
|
||||||
{ type: 'text', text: userText },
|
{ type: 'text', text: merged.currentText },
|
||||||
...images.map((item) => ({
|
...images.map((item) => ({
|
||||||
type: 'image_url',
|
type: 'image_url',
|
||||||
image_url: { url: imageDataUrl(item.image) }
|
image_url: { url: imageDataUrl(item.image) }
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
: userText;
|
: merged.currentText;
|
||||||
return [
|
return [
|
||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
|
...merged.items.map((item) => ({ role: item.role, content: item.text })),
|
||||||
{ role: 'user', content: userContent }
|
{ role: 'user', content: userContent }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAnthropicPayload(cfg, prompt) {
|
function buildAnthropicPayload(cfg, prompt, history) {
|
||||||
|
const merged = mergeHistory(history, prompt.userText);
|
||||||
const content = prompt.images.length
|
const content = prompt.images.length
|
||||||
? [
|
? [
|
||||||
{ type: 'text', text: prompt.userText },
|
{ type: 'text', text: merged.currentText },
|
||||||
...prompt.images.map((item) => ({
|
...prompt.images.map((item) => ({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
source: {
|
source: {
|
||||||
@@ -88,20 +121,24 @@ function buildAnthropicPayload(cfg, prompt) {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
: prompt.userText;
|
: merged.currentText;
|
||||||
return {
|
return {
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
system: prompt.system,
|
system: prompt.system,
|
||||||
messages: [{ role: 'user', content }],
|
messages: [
|
||||||
|
...merged.items.map((item) => ({ role: item.role, content: item.text })),
|
||||||
|
{ role: 'user', content }
|
||||||
|
],
|
||||||
temperature: cfg.temperature,
|
temperature: cfg.temperature,
|
||||||
max_tokens: cfg.maxTokens,
|
max_tokens: cfg.maxTokens,
|
||||||
stream: true
|
stream: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildResponsesPayload(cfg, prompt) {
|
function buildResponsesPayload(cfg, prompt, history) {
|
||||||
|
const merged = mergeHistory(history, prompt.userText);
|
||||||
const content = [
|
const content = [
|
||||||
{ type: 'input_text', text: prompt.userText },
|
{ type: 'input_text', text: merged.currentText },
|
||||||
...prompt.images.map((item) => ({
|
...prompt.images.map((item) => ({
|
||||||
type: 'input_image',
|
type: 'input_image',
|
||||||
image_url: imageDataUrl(item.image)
|
image_url: imageDataUrl(item.image)
|
||||||
@@ -110,7 +147,12 @@ function buildResponsesPayload(cfg, prompt) {
|
|||||||
return {
|
return {
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
instructions: prompt.system,
|
instructions: prompt.system,
|
||||||
input: [{ role: 'user', content }],
|
input: [
|
||||||
|
// 纯字符串是 Responses 输入消息的合法简写,同时绕开 input_text/output_text
|
||||||
|
// 的角色约束:input_text 不接受 assistant,output_text 只出现在带 id 的输出项里。
|
||||||
|
...merged.items.map((item) => ({ role: item.role, content: item.text })),
|
||||||
|
{ role: 'user', content }
|
||||||
|
],
|
||||||
temperature: cfg.temperature,
|
temperature: cfg.temperature,
|
||||||
max_output_tokens: cfg.maxTokens,
|
max_output_tokens: cfg.maxTokens,
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -118,8 +160,14 @@ function buildResponsesPayload(cfg, prompt) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessages(task, text, question, visualContexts) {
|
function buildMessages(task, text, question, visualContexts, history) {
|
||||||
return buildMessagesFromNormalized(task, text, question, normalizeVisualContexts(visualContexts));
|
return buildMessagesFromNormalized(
|
||||||
|
task,
|
||||||
|
text,
|
||||||
|
question,
|
||||||
|
normalizeVisualContexts(visualContexts),
|
||||||
|
history
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function endpointFor(baseUrl, protocol) {
|
function endpointFor(baseUrl, protocol) {
|
||||||
@@ -144,24 +192,42 @@ function headersFor(cfg) {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
function payloadFor(cfg, task, text, question, visuals) {
|
function payloadFor(cfg, task, text, question, visuals, history) {
|
||||||
const prompt = buildPromptFromNormalized(task, text, question, visuals);
|
const prompt = buildPromptFromNormalized(task, text, question, visuals);
|
||||||
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt);
|
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt, history);
|
||||||
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt);
|
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt, history);
|
||||||
return {
|
return {
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
messages: buildMessagesFromNormalized(task, text, question, visuals),
|
messages: buildMessagesFromNormalized(task, text, question, visuals, history),
|
||||||
temperature: cfg.temperature,
|
temperature: cfg.temperature,
|
||||||
max_tokens: cfg.maxTokens,
|
max_tokens: cfg.maxTokens,
|
||||||
stream: true
|
stream: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 正文不再由本地截断,超出模型窗口时只能由接口报错。各家措辞不同,
|
||||||
|
// 统一识别成一句可操作的中文提示,否则用户只会看到一串英文而不知道该缩小范围。
|
||||||
|
const CONTEXT_OVERFLOW_RE = /context[_\s-]?length|context window|maximum context|too many tokens|prompt is too long|reduce the length|input length|exceeds? the (?:maximum|context)/i;
|
||||||
|
|
||||||
|
function isContextOverflow(message, status) {
|
||||||
|
// 413 单看状态码就够:请求体过大只可能是上下文塞太多
|
||||||
|
if (status === 413) return true;
|
||||||
|
if (!CONTEXT_OVERFLOW_RE.test(String(message || ''))) return false;
|
||||||
|
return status === undefined || status === 400 || status === 422;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overflowHint(message) {
|
||||||
|
return `上下文超出模型窗口,请把范围改小(如改用"当前页"或选中片段)或换用更大窗口的模型。接口原文:${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseErrorBody(text, status) {
|
function parseErrorBody(text, status) {
|
||||||
try {
|
try {
|
||||||
const j = JSON.parse(text);
|
const j = JSON.parse(text);
|
||||||
const msg = (j.error && (j.error.message || j.error)) || j.message;
|
const msg = (j.error && (j.error.message || j.error)) || j.message;
|
||||||
if (msg) return String(msg);
|
if (msg) {
|
||||||
|
const s = String(msg);
|
||||||
|
return isContextOverflow(s, status) ? overflowHint(s) : s;
|
||||||
|
}
|
||||||
} catch (e) { /* 非 JSON */ }
|
} catch (e) { /* 非 JSON */ }
|
||||||
if (status === 401 || status === 403) return 'API Key 无效或没有权限';
|
if (status === 401 || status === 403) return 'API Key 无效或没有权限';
|
||||||
if (status === 404) return '接口地址或模型名称不存在';
|
if (status === 404) return '接口地址或模型名称不存在';
|
||||||
@@ -189,8 +255,8 @@ function streamFinished(protocol, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// onDelta 每收到一段增量就回调一次;返回完整文本。
|
// onDelta 每收到一段增量就回调一次;返回完整文本。
|
||||||
// signal 用于用户中途取消。
|
// signal 用于用户中途取消。history 是本轮之前的历史轮次,只消费 role 与 text。
|
||||||
async function stream({ task, text, question, visualContexts, signal, onDelta }) {
|
async function stream({ task, text, question, visualContexts, history, signal, onDelta }) {
|
||||||
const cfg = aiConfig.get();
|
const cfg = aiConfig.get();
|
||||||
const st = aiConfig.status();
|
const st = aiConfig.status();
|
||||||
if (!cfg.apiKey && !st.isLocal) throw new Error('尚未配置 API Key,请先在设置中填写');
|
if (!cfg.apiKey && !st.isLocal) throw new Error('尚未配置 API Key,请先在设置中填写');
|
||||||
@@ -203,7 +269,7 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
|
|||||||
const res = await fetchWithProxy(endpointFor(cfg.baseUrl, cfg.protocol), {
|
const res = await fetchWithProxy(endpointFor(cfg.baseUrl, cfg.protocol), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headersFor(cfg),
|
headers: headersFor(cfg),
|
||||||
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals)),
|
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals, history)),
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,11 +297,13 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
|
|||||||
// 部分服务端把错误放在流里返回
|
// 部分服务端把错误放在流里返回
|
||||||
if (j.error || j.type === 'error') {
|
if (j.error || j.type === 'error') {
|
||||||
const error = j.error || j;
|
const error = j.error || j;
|
||||||
throw new Error(error.message || String(error));
|
const message = error.message || String(error);
|
||||||
|
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
|
||||||
}
|
}
|
||||||
if (cfg.protocol === 'openai-responses' && ['response.failed', 'response.incomplete'].includes(j.type)) {
|
if (cfg.protocol === 'openai-responses' && ['response.failed', 'response.incomplete'].includes(j.type)) {
|
||||||
const error = j.response && (j.response.error || j.response.incomplete_details);
|
const error = j.response && (j.response.error || j.response.incomplete_details);
|
||||||
throw new Error((error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成');
|
const message = (error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成';
|
||||||
|
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
|
||||||
}
|
}
|
||||||
const piece = streamDelta(cfg.protocol, j);
|
const piece = streamDelta(cfg.protocol, j);
|
||||||
if (piece) {
|
if (piece) {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const atomic = require('../atomic-file');
|
||||||
|
|
||||||
|
const ASSET_RE = /^img_[a-f0-9]{64}$/;
|
||||||
|
const MIME_TYPE = 'image/jpeg';
|
||||||
|
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_BYTES = 256 * 1024 * 1024;
|
||||||
|
const GRACE_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
let rootDir = null;
|
||||||
|
|
||||||
|
function init(userDataDir) {
|
||||||
|
rootDir = path.join(userDataDir, 'reader-ai-images');
|
||||||
|
}
|
||||||
|
|
||||||
|
function directory() {
|
||||||
|
if (rootDir) return rootDir;
|
||||||
|
const home = process.env.APPDATA || process.env.HOME || process.cwd();
|
||||||
|
return path.join(home, 'PeopleLib', 'reader-ai-images');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeImageId(value) {
|
||||||
|
const id = String(value == null ? '' : value);
|
||||||
|
if (!ASSET_RE.test(id)) throw new Error('会话图像标识无效');
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileOf(imageId) {
|
||||||
|
return path.join(directory(), `${safeImageId(imageId)}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyJpeg(bytes) {
|
||||||
|
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
|
||||||
|
throw new Error('会话图像不是有效的 JPEG');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上一次写入被中断会留下 .tmp,'wx' 会因此永久失败,这里清掉再重试一次
|
||||||
|
function writeBlob(dest, bytes) {
|
||||||
|
const temp = `${dest}.tmp`;
|
||||||
|
try {
|
||||||
|
atomic.writeBytesExclusive(dest, bytes);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || error.code !== 'EEXIST' || !fs.existsSync(temp)) throw error;
|
||||||
|
fs.unlinkSync(temp);
|
||||||
|
atomic.writeBytesExclusive(dest, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function put(buffer, mimeType) {
|
||||||
|
if (String(mimeType == null ? '' : mimeType).toLowerCase() !== MIME_TYPE) {
|
||||||
|
throw new Error('会话图像仅支持 JPEG');
|
||||||
|
}
|
||||||
|
let bytes = null;
|
||||||
|
if (Buffer.isBuffer(buffer)) bytes = buffer;
|
||||||
|
else if (buffer instanceof Uint8Array) bytes = Buffer.from(buffer);
|
||||||
|
if (!bytes || !bytes.length) throw new Error('会话图像数据为空');
|
||||||
|
if (bytes.length > MAX_IMAGE_BYTES) throw new Error('会话图像超过 3 MB');
|
||||||
|
verifyJpeg(bytes);
|
||||||
|
const imageId = `img_${crypto.createHash('sha256').update(bytes).digest('hex')}`;
|
||||||
|
const dest = fileOf(imageId);
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
if (totalBytes() + bytes.length > MAX_TOTAL_BYTES) throw new Error('会话图像总量已达上限');
|
||||||
|
writeBlob(dest, bytes);
|
||||||
|
}
|
||||||
|
return { imageId, bytes: bytes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(imageId) {
|
||||||
|
const file = fileOf(imageId);
|
||||||
|
let stat = null;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(file);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('会话图像不存在');
|
||||||
|
}
|
||||||
|
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error('会话图像为空或超过 3 MB');
|
||||||
|
}
|
||||||
|
const bytes = fs.readFileSync(file);
|
||||||
|
verifyJpeg(bytes);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrl(imageId) {
|
||||||
|
return `data:${MIME_TYPE};base64,${read(imageId).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBlobs() {
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(directory());
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const blobs = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const match = /^(img_[a-f0-9]{64})\.jpg$/.exec(name);
|
||||||
|
if (!match) continue;
|
||||||
|
const file = path.join(directory(), name);
|
||||||
|
let stat = null;
|
||||||
|
try { stat = fs.statSync(file); } catch (error) { continue; }
|
||||||
|
if (!stat.isFile()) continue;
|
||||||
|
blobs.push({ imageId: match[1], file, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||||
|
}
|
||||||
|
return blobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图像先落盘、再被会话引用,中间存在窗口期。
|
||||||
|
// 宽限期内的新文件一律不删,否则一次并发的清理就能抹掉正在提交的图像。
|
||||||
|
function cleanup(referencedIds, options) {
|
||||||
|
const opts = options && typeof options === 'object' ? options : {};
|
||||||
|
const graceMs = Number.isFinite(Number(opts.graceMs)) && Number(opts.graceMs) >= 0
|
||||||
|
? Number(opts.graceMs)
|
||||||
|
: GRACE_MS;
|
||||||
|
const keep = new Set();
|
||||||
|
for (const id of Array.from(referencedIds || [])) {
|
||||||
|
const text = String(id == null ? '' : id);
|
||||||
|
if (ASSET_RE.test(text)) keep.add(text);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
let removed = 0;
|
||||||
|
for (const blob of listBlobs()) {
|
||||||
|
if (keep.has(blob.imageId)) continue;
|
||||||
|
// mtimeMs 在 ext4/APFS 上带亚毫秒小数,而 Date.now() 只到整毫秒,
|
||||||
|
// 刚落盘的文件算出来的年龄会是负数,graceMs 为 0 时也永远删不掉
|
||||||
|
if (now - Math.floor(blob.mtimeMs) < graceMs) continue;
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(blob.file);
|
||||||
|
removed++;
|
||||||
|
} catch (error) { /* 单个失败不影响其余回收 */ }
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalBytes() {
|
||||||
|
let total = 0;
|
||||||
|
for (const blob of listBlobs()) total += blob.size;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
safeImageId,
|
||||||
|
put,
|
||||||
|
read,
|
||||||
|
dataUrl,
|
||||||
|
cleanup,
|
||||||
|
totalBytes,
|
||||||
|
MIME_TYPE,
|
||||||
|
MAX_IMAGE_BYTES,
|
||||||
|
MAX_TOTAL_BYTES,
|
||||||
|
GRACE_MS
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const atomic = require('../atomic-file');
|
||||||
|
|
||||||
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
|
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
|
||||||
const MAX_OBJECTS = 5000;
|
const MAX_OBJECTS = 5000;
|
||||||
@@ -11,10 +12,12 @@ const DOCUMENT_SAMPLE_BYTES = 4 * 1024 * 1024;
|
|||||||
|
|
||||||
let rootDir = null;
|
let rootDir = null;
|
||||||
let documentKeys = new Map();
|
let documentKeys = new Map();
|
||||||
|
let countCache = new Map();
|
||||||
|
|
||||||
function init(userDataDir) {
|
function init(userDataDir) {
|
||||||
rootDir = path.join(userDataDir, 'reader-annotations');
|
rootDir = path.join(userDataDir, 'reader-annotations');
|
||||||
documentKeys = new Map();
|
documentKeys = new Map();
|
||||||
|
countCache = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
function directory() {
|
function directory() {
|
||||||
@@ -136,29 +139,7 @@ function read(entryId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function write(entryId, data) {
|
function write(entryId, data) {
|
||||||
const dest = fileOf(entryId);
|
atomic.writeJson(fileOf(entryId), data);
|
||||||
const temp = `${dest}.tmp`;
|
|
||||||
const backup = `${dest}.bak`;
|
|
||||||
let backedUp = false;
|
|
||||||
fs.mkdirSync(directory(), { recursive: true });
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(temp, JSON.stringify(data, null, 2), 'utf8');
|
|
||||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
fs.renameSync(dest, backup);
|
|
||||||
backedUp = true;
|
|
||||||
}
|
|
||||||
fs.renameSync(temp, 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) { /* 下次读取时恢复 */ }
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function get(entryId, documentKey) {
|
function get(entryId, documentKey) {
|
||||||
@@ -205,8 +186,93 @@ function setPage(entryId, documentKey, page, pageData) {
|
|||||||
return { page: Number(pageKey), count: clean.objects.length, updatedAt: doc.updatedAt };
|
return { page: Number(pageKey), count: clean.objects.length, updatedAt: doc.updatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countObjects(data) {
|
||||||
|
let total = 0;
|
||||||
|
for (const doc of Object.values(data.documents || {})) {
|
||||||
|
if (!doc || typeof doc !== 'object' || !doc.pages || typeof doc.pages !== 'object') continue;
|
||||||
|
for (const page of Object.values(doc.pages)) {
|
||||||
|
if (page && Array.isArray(page.objects)) total += page.objects.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批注文件单个可达 64 MB,而书库每次刷新都要取一遍计数,
|
||||||
|
// 按 mtime + size 缓存避免重复解析未改动的文件
|
||||||
|
function getCounts() {
|
||||||
|
const counts = {};
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
|
||||||
|
} catch (e) {
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
for (const name of names) {
|
||||||
|
const entryId = name.slice(0, -5);
|
||||||
|
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
|
||||||
|
seen.add(entryId);
|
||||||
|
const file = path.join(directory(), name);
|
||||||
|
let stat = null;
|
||||||
|
try { stat = fs.statSync(file); } catch (e) { continue; }
|
||||||
|
const cached = countCache.get(entryId);
|
||||||
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||||
|
if (cached.count > 0) counts[entryId] = cached.count;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
try {
|
||||||
|
count = countObjects(parseDocument(file, entryId));
|
||||||
|
} catch (e) {
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
countCache.set(entryId, { mtimeMs: stat.mtimeMs, size: stat.size, count });
|
||||||
|
if (count > 0) counts[entryId] = count;
|
||||||
|
}
|
||||||
|
for (const key of Array.from(countCache.keys())) {
|
||||||
|
if (!seen.has(key)) countCache.delete(key);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批注没有独立的浏览界面,条目一旦离开书库就再也看不到,
|
||||||
|
// 因此对账时连体积一起报出来,便于用户判断是否回收
|
||||||
|
function orphanReport(knownIds) {
|
||||||
|
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
|
||||||
|
const counts = getCounts();
|
||||||
|
const orphans = [];
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
|
||||||
|
} catch (e) {
|
||||||
|
return orphans;
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
const entryId = name.slice(0, -5);
|
||||||
|
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
|
||||||
|
if (known.has(entryId)) continue;
|
||||||
|
let size = 0;
|
||||||
|
try { size = fs.statSync(path.join(directory(), name)).size; } catch (e) { continue; }
|
||||||
|
orphans.push({ entryId, count: counts[entryId] || 0, bytes: size });
|
||||||
|
}
|
||||||
|
orphans.sort((a, b) => b.bytes - a.bytes || a.entryId.localeCompare(b.entryId));
|
||||||
|
return orphans;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetMany(entryIds) {
|
||||||
|
const ids = Array.isArray(entryIds) ? entryIds : [];
|
||||||
|
let removed = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
if (forget(id)) removed++;
|
||||||
|
} catch (e) { /* 单个失败不影响其余回收 */ }
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
function forget(entryId) {
|
function forget(entryId) {
|
||||||
const file = fileOf(entryId);
|
const file = fileOf(entryId);
|
||||||
|
countCache.delete(normalizeEntryId(entryId));
|
||||||
let removed = false;
|
let removed = false;
|
||||||
let targets = [file, `${file}.tmp`, `${file}.bak`];
|
let targets = [file, `${file}.tmp`, `${file}.bak`];
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +302,9 @@ module.exports = {
|
|||||||
hashDocumentFile,
|
hashDocumentFile,
|
||||||
get,
|
get,
|
||||||
setPage,
|
setPage,
|
||||||
|
getCounts,
|
||||||
|
orphanReport,
|
||||||
|
forgetMany,
|
||||||
forget,
|
forget,
|
||||||
LARGE_DOCUMENT_BYTES,
|
LARGE_DOCUMENT_BYTES,
|
||||||
DOCUMENT_SAMPLE_BYTES
|
DOCUMENT_SAMPLE_BYTES
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// 笔记独立窗口的生命周期管理。
|
||||||
|
// 单窗口多标签,形态与阅读器一致:窗口只有一个,一条笔记占一个标签。
|
||||||
|
//
|
||||||
|
// 「一标签一条」是数据安全约束,不是体验优化:`reader:updateNote` 是整条覆盖、
|
||||||
|
// 无版本校验,同一条笔记开两个编辑器时后保存者会把前者的内容整块吃掉。
|
||||||
|
//
|
||||||
|
// openNotes 是主进程侧的标签集镜像,由渲染层通过 `notes:tabsChanged` 上报。
|
||||||
|
// 它同时承担授权职责(见 ownsNote),所以不能只信渲染层:新增标签一律先过
|
||||||
|
// main.js 的 findNote() 用 listNotes() 对账。
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
const { BrowserWindow } = require('electron');
|
||||||
|
|
||||||
|
const CLOSE_TIMEOUT = 10000;
|
||||||
|
|
||||||
|
let win = null;
|
||||||
|
// noteId -> entryId。删除对账要按 entryId 找标签,所以存的是映射不是集合。
|
||||||
|
const openNotes = new Map();
|
||||||
|
let onChanged = null;
|
||||||
|
let closeAllowed = false;
|
||||||
|
let closePending = false;
|
||||||
|
let closeTimer = null;
|
||||||
|
|
||||||
|
function alive(target) {
|
||||||
|
return !!target && !target.isDestroyed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(noteId) {
|
||||||
|
return String(noteId == null ? '' : noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
if (alive(win)) return win;
|
||||||
|
win = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openIds() {
|
||||||
|
if (!get()) return [];
|
||||||
|
return [...openNotes.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyChanged() {
|
||||||
|
if (typeof onChanged === 'function') onChanged(openIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChangeListener(fn) {
|
||||||
|
onChanged = typeof fn === 'function' ? fn : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageUrl(rootDir) {
|
||||||
|
return pathToFileURL(path.join(rootDir, 'src', 'ui', 'note.html')).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNoteSender(wc) {
|
||||||
|
const target = get();
|
||||||
|
return !!target && !!wc && target.webContents.id === wc.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromWebContents(wc) {
|
||||||
|
return isNoteSender(wc) ? 'note' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔记窗口只能读自己已经打开的那些标签。放宽成「只要是笔记窗口就给」
|
||||||
|
// 会让这个通道变成遍历全部笔记的后门。
|
||||||
|
function ownsNote(wc, noteId) {
|
||||||
|
if (!isNoteSender(wc)) return false;
|
||||||
|
return openNotes.has(keyOf(noteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染层只能"收窄"标签集(上报自己关掉了哪些),不能新增。
|
||||||
|
// 允许新增等于让渲染层自己扩权:谎报持有某条笔记,随后 notes:getOne 就放行了。
|
||||||
|
// 新增只能走 open(),那条路径在 main.js 里过 findNote() 对账。
|
||||||
|
function setTabs(wc, noteIds) {
|
||||||
|
if (!isNoteSender(wc)) return false;
|
||||||
|
const claimed = new Set();
|
||||||
|
for (const item of Array.isArray(noteIds) ? noteIds : []) {
|
||||||
|
const id = keyOf(item && item.noteId != null ? item.noteId : item);
|
||||||
|
if (id) claimed.add(id);
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
for (const key of [...openNotes.keys()]) {
|
||||||
|
if (claimed.has(key)) continue;
|
||||||
|
openNotes.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) notifyChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToWindow(channel, payload) {
|
||||||
|
const target = get();
|
||||||
|
if (!target) return false;
|
||||||
|
target.webContents.send(channel, payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function create(rootDir, uiTheme) {
|
||||||
|
closeAllowed = false;
|
||||||
|
closePending = false;
|
||||||
|
win = new BrowserWindow({
|
||||||
|
width: 1080,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 720,
|
||||||
|
minHeight: 520,
|
||||||
|
frame: false,
|
||||||
|
backgroundColor: '#141414',
|
||||||
|
icon: path.join(
|
||||||
|
rootDir,
|
||||||
|
'icons',
|
||||||
|
'dist',
|
||||||
|
uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico'
|
||||||
|
),
|
||||||
|
title: 'PeopleLib',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(rootDir, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
spellcheck: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const created = win;
|
||||||
|
|
||||||
|
created.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||||
|
created.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (!String(url).startsWith(pageUrl(rootDir))) event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 未保存的编辑要在窗口消失之前问用户,所以必须先拦下 close 交给渲染层。
|
||||||
|
// 渲染层卡住时靠看门狗兜底,否则窗口永远关不掉。
|
||||||
|
created.on('close', (event) => {
|
||||||
|
if (closeAllowed || !alive(created)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (closePending) return;
|
||||||
|
closePending = true;
|
||||||
|
created.webContents.send('notes:prepareClose', null);
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
if (alive(created)) {
|
||||||
|
closeAllowed = true;
|
||||||
|
created.destroy();
|
||||||
|
}
|
||||||
|
}, CLOSE_TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
created.on('closed', () => {
|
||||||
|
if (win === created) win = null;
|
||||||
|
openNotes.clear();
|
||||||
|
closeAllowed = false;
|
||||||
|
closePending = false;
|
||||||
|
if (closeTimer) clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
notifyChanged();
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(entryId, noteId, rootDir, uiTheme = 'dark') {
|
||||||
|
const key = keyOf(noteId);
|
||||||
|
const entry = String(entryId);
|
||||||
|
const existing = get();
|
||||||
|
if (existing) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.focus();
|
||||||
|
// 已经开着的标签由渲染层激活,不新建第二个编辑器
|
||||||
|
existing.webContents.send('notes:openTab', { entryId: entry, noteId: key });
|
||||||
|
if (!openNotes.has(key)) {
|
||||||
|
openNotes.set(key, entry);
|
||||||
|
notifyChanged();
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = create(rootDir, uiTheme);
|
||||||
|
openNotes.set(key, entry);
|
||||||
|
created.loadFile(path.join(rootDir, 'src', 'ui', 'note.html'), {
|
||||||
|
query: { entryId: entry, noteId: key }
|
||||||
|
});
|
||||||
|
notifyChanged();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔记在别处被删除后标签必须自己退场,否则它下一次保存会把已删条目整条写回去
|
||||||
|
function closeFor(noteId) {
|
||||||
|
const key = keyOf(noteId);
|
||||||
|
if (!get() || !openNotes.has(key)) return false;
|
||||||
|
sendToWindow('notes:closeTab', { noteIds: [key] });
|
||||||
|
openNotes.delete(key);
|
||||||
|
notifyChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMany(noteIds) {
|
||||||
|
const keys = (Array.isArray(noteIds) ? noteIds : [])
|
||||||
|
.map((id) => keyOf(id))
|
||||||
|
.filter((id) => openNotes.has(id));
|
||||||
|
if (!get() || !keys.length) return 0;
|
||||||
|
sendToWindow('notes:closeTab', { noteIds: keys });
|
||||||
|
for (const key of keys) openNotes.delete(key);
|
||||||
|
notifyChanged();
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForEntries(entryIds) {
|
||||||
|
const targets = new Set((Array.isArray(entryIds) ? entryIds : []).map((id) => String(id)));
|
||||||
|
if (!get() || !targets.size) return 0;
|
||||||
|
const keys = [];
|
||||||
|
for (const [key, entryId] of openNotes) {
|
||||||
|
if (targets.has(entryId)) keys.push(key);
|
||||||
|
}
|
||||||
|
if (!keys.length) return 0;
|
||||||
|
sendToWindow('notes:closeTab', { noteIds: keys });
|
||||||
|
for (const key of keys) openNotes.delete(key);
|
||||||
|
notifyChanged();
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染层处理完未保存提示后才真正放行关闭
|
||||||
|
function shutdownReady(wc) {
|
||||||
|
const target = get();
|
||||||
|
if (!target || !isNoteSender(wc) || !closePending) return false;
|
||||||
|
if (closeTimer) clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
closeAllowed = true;
|
||||||
|
closePending = false;
|
||||||
|
target.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户在未保存提示里选了取消。必须复位 closePending,否则下一次点关闭会被
|
||||||
|
// 「已在处理中」挡掉,窗口再也关不上;也必须撤掉看门狗,否则十秒后它会把
|
||||||
|
// 带着未保存内容的窗口直接销毁。
|
||||||
|
function cancelClose(wc) {
|
||||||
|
if (!isNoteSender(wc) || !closePending) return false;
|
||||||
|
if (closeTimer) clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
closePending = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function all() {
|
||||||
|
const target = get();
|
||||||
|
return target ? [target] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
open, get, all, openIds, closeFor, closeMany, closeForEntries,
|
||||||
|
fromWebContents, ownsNote, setTabs, shutdownReady, cancelClose, setChangeListener
|
||||||
|
};
|
||||||
@@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const atomic = require('../atomic-file');
|
||||||
|
|
||||||
const VERSION = 6;
|
const VERSION = 6;
|
||||||
const STANDALONE_ENTRY_ID = 'system:standalone-notes';
|
const STANDALONE_ENTRY_ID = 'system:standalone-notes';
|
||||||
const LIMITS = {
|
const LIMITS = {
|
||||||
id: 160,
|
id: 160,
|
||||||
title: 500,
|
title: 500,
|
||||||
text: 20000,
|
canvasText: 20000,
|
||||||
quote: 10000,
|
quote: 10000,
|
||||||
context: 20000,
|
context: 20000,
|
||||||
aiTask: 500,
|
aiTask: 500,
|
||||||
@@ -23,7 +24,7 @@ const LIMITS = {
|
|||||||
locatorJson: 50000,
|
locatorJson: 50000,
|
||||||
richBlocks: 500,
|
richBlocks: 500,
|
||||||
richOps: 5000,
|
richOps: 5000,
|
||||||
richJson: 12 * 1024 * 1024,
|
canvasRichJson: 12 * 1024 * 1024,
|
||||||
richImages: 12,
|
richImages: 12,
|
||||||
richImageBytes: 2 * 1024 * 1024,
|
richImageBytes: 2 * 1024 * 1024,
|
||||||
richImageTotalBytes: 8 * 1024 * 1024,
|
richImageTotalBytes: 8 * 1024 * 1024,
|
||||||
@@ -315,7 +316,6 @@ function normalizeRichContent(input) {
|
|||||||
}
|
}
|
||||||
if (value.ops.length > LIMITS.richOps) throw new Error('富文本笔记内容过多');
|
if (value.ops.length > LIMITS.richOps) throw new Error('富文本笔记内容过多');
|
||||||
const result = { version: 2, ops: [] };
|
const result = { version: 2, ops: [] };
|
||||||
let textLength = 0;
|
|
||||||
let imageCount = 0;
|
let imageCount = 0;
|
||||||
let imageBytes = 0;
|
let imageBytes = 0;
|
||||||
for (const op of value.ops) {
|
for (const op of value.ops) {
|
||||||
@@ -324,8 +324,6 @@ function normalizeRichContent(input) {
|
|||||||
}
|
}
|
||||||
const attributes = normalizeRichAttributes(op.attributes);
|
const attributes = normalizeRichAttributes(op.attributes);
|
||||||
if (typeof op.insert === 'string') {
|
if (typeof op.insert === 'string') {
|
||||||
textLength += op.insert.length;
|
|
||||||
if (textLength > LIMITS.text) throw new Error('富文本笔记文字过多');
|
|
||||||
if (op.insert) result.ops.push({
|
if (op.insert) result.ops.push({
|
||||||
insert: op.insert,
|
insert: op.insert,
|
||||||
...(attributes ? { attributes } : {})
|
...(attributes ? { attributes } : {})
|
||||||
@@ -348,7 +346,6 @@ function normalizeRichContent(input) {
|
|||||||
}
|
}
|
||||||
result.ops.push({ insert: { image: image.dataUrl } });
|
result.ops.push({ insert: { image: image.dataUrl } });
|
||||||
}
|
}
|
||||||
if (JSON.stringify(result).length > LIMITS.richJson) throw new Error('富文本笔记过大');
|
|
||||||
return hasRichContent(result) ? result : null;
|
return hasRichContent(result) ? result : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,8 +355,7 @@ function richPlainText(content) {
|
|||||||
.filter((op) => typeof op.insert === 'string')
|
.filter((op) => typeof op.insert === 'string')
|
||||||
.map((op) => op.insert)
|
.map((op) => op.insert)
|
||||||
.join('')
|
.join('')
|
||||||
.replace(/\n$/, '')
|
.replace(/\n$/, '');
|
||||||
.slice(0, LIMITS.text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRichContent(content) {
|
function hasRichContent(content) {
|
||||||
@@ -430,7 +426,7 @@ function normalizeCanvasObject(value, imageTotals) {
|
|||||||
throw new Error('画布对象线宽无效');
|
throw new Error('画布对象线宽无效');
|
||||||
}
|
}
|
||||||
if (value.canvasKind === 'text'
|
if (value.canvasKind === 'text'
|
||||||
&& (typeof value.text !== 'string' || value.text.length > LIMITS.text)) {
|
&& (typeof value.text !== 'string' || value.text.length > LIMITS.canvasText)) {
|
||||||
throw new Error('画布文字无效');
|
throw new Error('画布文字无效');
|
||||||
}
|
}
|
||||||
if ((value.canvasKind === 'pen' || value.canvasKind === 'highlight')
|
if ((value.canvasKind === 'pen' || value.canvasKind === 'highlight')
|
||||||
@@ -474,7 +470,7 @@ function normalizeCanvasFlow(value, pageIds, firstPageId) {
|
|||||||
}
|
}
|
||||||
if (typeof op.insert === 'string') {
|
if (typeof op.insert === 'string') {
|
||||||
textLength += op.insert.length;
|
textLength += op.insert.length;
|
||||||
if (textLength > LIMITS.text) throw new Error('画布全局文本文字过多');
|
if (textLength > LIMITS.canvasText) throw new Error('画布全局文本文字过多');
|
||||||
const attributes = normalizeRichAttributes(op.attributes);
|
const attributes = normalizeRichAttributes(op.attributes);
|
||||||
if (op.insert) result.ops.push({
|
if (op.insert) result.ops.push({
|
||||||
insert: op.insert,
|
insert: op.insert,
|
||||||
@@ -498,7 +494,7 @@ function normalizeCanvasFlow(value, pageIds, firstPageId) {
|
|||||||
breakIds.add(pageId);
|
breakIds.add(pageId);
|
||||||
result.ops.push({ insert: { canvasPageBreak: pageId } });
|
result.ops.push({ insert: { canvasPageBreak: pageId } });
|
||||||
}
|
}
|
||||||
if (JSON.stringify(result).length > LIMITS.richJson) throw new Error('画布全局文本过大');
|
if (JSON.stringify(result).length > LIMITS.canvasRichJson) throw new Error('画布全局文本过大');
|
||||||
const meaningful = result.ops.some((op) => (
|
const meaningful = result.ops.some((op) => (
|
||||||
typeof op.insert === 'string' ? op.insert.trim() : !!op.insert.canvasPageBreak
|
typeof op.insert === 'string' ? op.insert.trim() : !!op.insert.canvasPageBreak
|
||||||
));
|
));
|
||||||
@@ -592,14 +588,15 @@ function canvasPlainText(content) {
|
|||||||
return [flowText, objectText]
|
return [flowText, objectText]
|
||||||
.filter((text) => text.trim())
|
.filter((text) => text.trim())
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.slice(0, LIMITS.text);
|
.slice(0, LIMITS.canvasText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function notePlainText(richContent, canvasContent, fallback) {
|
function notePlainText(richContent, canvasContent, fallback) {
|
||||||
return [
|
const text = [
|
||||||
richContent ? richPlainText(richContent) : String(fallback || ''),
|
richContent ? richPlainText(richContent) : String(fallback == null ? '' : fallback),
|
||||||
canvasPlainText(canvasContent)
|
canvasPlainText(canvasContent)
|
||||||
].filter((value) => value.trim()).join('\n').slice(0, LIMITS.text);
|
].filter((value) => value.trim()).join('\n');
|
||||||
|
return canvasContent ? text.slice(0, LIMITS.canvasText) : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCanvasContent(content) {
|
function hasCanvasContent(content) {
|
||||||
@@ -792,24 +789,7 @@ function migrate(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const dest = getFilePath();
|
atomic.writeJson(getFilePath(), cache);
|
||||||
const temp = `${dest}.tmp`;
|
|
||||||
const backup = `${dest}.bak`;
|
|
||||||
let backedUp = false;
|
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
|
|
||||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
|
||||||
if (fs.existsSync(dest)) { fs.renameSync(dest, backup); backedUp = true; }
|
|
||||||
fs.renameSync(temp, dest);
|
|
||||||
if (backedUp) { try { fs.unlinkSync(backup); } catch (e) { /* ignore */ } }
|
|
||||||
} 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) { /* 下次 load 时恢复 */ }
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
@@ -1039,7 +1019,7 @@ function noteInput(note, c) {
|
|||||||
if (noteType === 'canvas' && !hasCanvasContent(canvasContent)) {
|
if (noteType === 'canvas' && !hasCanvasContent(canvasContent)) {
|
||||||
throw new Error('画布笔记内容为空');
|
throw new Error('画布笔记内容为空');
|
||||||
}
|
}
|
||||||
const text = notePlainText(richContent, canvasContent, limitedString(note.text, LIMITS.text));
|
const text = notePlainText(richContent, canvasContent, note.text);
|
||||||
const quote = limitedString(note.quote, LIMITS.quote);
|
const quote = limitedString(note.quote, LIMITS.quote);
|
||||||
if (!text.trim() && !quote.trim()
|
if (!text.trim() && !quote.trim()
|
||||||
&& !hasRichContent(richContent) && !hasCanvasContent(canvasContent)) {
|
&& !hasRichContent(richContent) && !hasCanvasContent(canvasContent)) {
|
||||||
@@ -1120,7 +1100,7 @@ function updateNote(id, noteId, patch) {
|
|||||||
else delete note.canvasContent;
|
else delete note.canvasContent;
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(patch, 'text')) {
|
if (Object.prototype.hasOwnProperty.call(patch, 'text')) {
|
||||||
fallbackText = limitedString(patch.text, LIMITS.text);
|
fallbackText = String(patch.text == null ? '' : patch.text);
|
||||||
if (note.noteType !== 'canvas'
|
if (note.noteType !== 'canvas'
|
||||||
&& !Object.prototype.hasOwnProperty.call(patch, 'richContent')) {
|
&& !Object.prototype.hasOwnProperty.call(patch, 'richContent')) {
|
||||||
delete note.richContent;
|
delete note.richContent;
|
||||||
@@ -1345,6 +1325,46 @@ function removeCollection(collectionId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 与书库对账:列出书库里已不存在的条目。这些阅读资料在「我的笔记」里仍然可见,
|
||||||
|
// 属于有意保留,因此只报告不自动删除,由用户显式决定。
|
||||||
|
function orphanReport(knownIds) {
|
||||||
|
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
|
||||||
|
const c = load();
|
||||||
|
const orphans = [];
|
||||||
|
for (const [entryId, entry] of Object.entries(c.entries)) {
|
||||||
|
if (entryId === STANDALONE_ENTRY_ID) continue;
|
||||||
|
if (known.has(entryId)) continue;
|
||||||
|
const notes = Array.isArray(entry.notes) ? entry.notes.length : 0;
|
||||||
|
const bookmarks = Array.isArray(entry.bookmarks) ? entry.bookmarks.length : 0;
|
||||||
|
const snapshot = entry.book || {};
|
||||||
|
orphans.push({
|
||||||
|
entryId,
|
||||||
|
title: snapshot.title || '',
|
||||||
|
notes,
|
||||||
|
bookmarks,
|
||||||
|
hasProgress: !!entry.progress
|
||||||
|
});
|
||||||
|
}
|
||||||
|
orphans.sort((a, b) => b.notes - a.notes || a.entryId.localeCompare(b.entryId));
|
||||||
|
return orphans;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetMany(entryIds) {
|
||||||
|
const ids = (Array.isArray(entryIds) ? entryIds : []).map((id) => safeId(id, '条目 ID'));
|
||||||
|
if (!ids.length) return 0;
|
||||||
|
let removed = 0;
|
||||||
|
mutateCache((current) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
if (id === STANDALONE_ENTRY_ID) continue;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(current.entries, id)) continue;
|
||||||
|
delete current.entries[id];
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
return removed > 0;
|
||||||
|
});
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
// forget 是显式删除:只清掉指定条目的阅读数据,不影响其它条目或笔记本。
|
// forget 是显式删除:只清掉指定条目的阅读数据,不影响其它条目或笔记本。
|
||||||
function forget(value) {
|
function forget(value) {
|
||||||
const id = safeId(value, '条目 ID');
|
const id = safeId(value, '条目 ID');
|
||||||
@@ -1363,5 +1383,5 @@ module.exports = {
|
|||||||
addNote, addStandaloneNote, updateNote, removeNote, listNotes, getNoteCounts,
|
addNote, addStandaloneNote, updateNote, removeNote, listNotes, getNoteCounts,
|
||||||
noteAssetIds,
|
noteAssetIds,
|
||||||
listCollections, addCollection, updateCollection, removeCollection,
|
listCollections, addCollection, updateCollection, removeCollection,
|
||||||
forget
|
orphanReport, forgetMany, forget
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const atomic = require('./atomic-file');
|
||||||
|
|
||||||
let filePath = null;
|
let filePath = null;
|
||||||
let cache = null;
|
let cache = null;
|
||||||
@@ -29,29 +30,7 @@ function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const dest = getFilePath();
|
atomic.writeJson(getFilePath(), cache);
|
||||||
const temp = `${dest}.tmp`;
|
|
||||||
const backup = `${dest}.bak`;
|
|
||||||
let backedUp = false;
|
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
|
|
||||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
fs.renameSync(dest, backup);
|
|
||||||
backedUp = true;
|
|
||||||
}
|
|
||||||
fs.renameSync(temp, dest);
|
|
||||||
if (backedUp) {
|
|
||||||
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响提交 */ }
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (e) { /* ignore */ }
|
|
||||||
try {
|
|
||||||
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
|
||||||
} catch (rollbackError) { /* 下次加载时会恢复 */ }
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function get(key, def) {
|
function get(key, def) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 PeopleLib/2.1.0 (+https://github.com/lofyer/peoplelib)';
|
||||||
const { fetch: undiciFetch, ProxyAgent } = require('undici');
|
const { fetch: undiciFetch, ProxyAgent } = require('undici');
|
||||||
|
|
||||||
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
|
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
|
||||||
|
|||||||
@@ -10,8 +10,29 @@ const libgen = require('./libgen');
|
|||||||
const zlib = require('./zlib');
|
const zlib = require('./zlib');
|
||||||
const scihub = require('./scihub');
|
const scihub = require('./scihub');
|
||||||
const motw = require('./motw');
|
const motw = require('./motw');
|
||||||
|
const openstax = require('./openstax');
|
||||||
|
const opentextbook = require('./opentextbook');
|
||||||
|
const wikisourceZh = require('./wikisource-zh');
|
||||||
|
const wikisourceEn = require('./wikisource-en');
|
||||||
|
|
||||||
const sources = [arxiv, gutenberg, openlibrary, doaj, pmc, biorxiv, standardebooks, semanticscholar, libgen, zlib, scihub, motw];
|
const sources = [
|
||||||
|
arxiv,
|
||||||
|
gutenberg,
|
||||||
|
openlibrary,
|
||||||
|
openstax,
|
||||||
|
opentextbook,
|
||||||
|
wikisourceZh,
|
||||||
|
wikisourceEn,
|
||||||
|
doaj,
|
||||||
|
pmc,
|
||||||
|
biorxiv,
|
||||||
|
standardebooks,
|
||||||
|
semanticscholar,
|
||||||
|
libgen,
|
||||||
|
zlib,
|
||||||
|
scihub,
|
||||||
|
motw
|
||||||
|
];
|
||||||
const byId = new Map(sources.map((s) => [s.id, s]));
|
const byId = new Map(sources.map((s) => [s.id, s]));
|
||||||
|
|
||||||
function listSources() {
|
function listSources() {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
const { fetchJson, clampPage, stripTags } = require('./http');
|
||||||
|
|
||||||
|
const BASE = 'https://openstax.org';
|
||||||
|
const CATALOG_URL = `${BASE}/apps/cms/api/books`;
|
||||||
|
const DETAIL_BASE = `${BASE}/apps/cms/api/v2/pages`;
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const CATALOG_TTL = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
let catalogPromise = null;
|
||||||
|
let catalogAt = 0;
|
||||||
|
|
||||||
|
function getJson(url) {
|
||||||
|
return fetchJson(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalog() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (catalogPromise && now - catalogAt < CATALOG_TTL) return catalogPromise;
|
||||||
|
const promise = getJson(CATALOG_URL).then((j) => {
|
||||||
|
if (!j || !Array.isArray(j.books)) throw new Error('OpenStax 返回了无法识别的书目');
|
||||||
|
return j.books.filter((b) => b && b.id && b.book_state === 'live');
|
||||||
|
});
|
||||||
|
catalogPromise = promise;
|
||||||
|
catalogAt = now;
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} catch (e) {
|
||||||
|
if (catalogPromise === promise) {
|
||||||
|
catalogPromise = null;
|
||||||
|
catalogAt = 0;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateId(postId) {
|
||||||
|
const id = String(postId || '');
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error('无效的 OpenStax ID');
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageUrl(book) {
|
||||||
|
const slug = String(book && book.slug || '').replace(/^\/+/, '');
|
||||||
|
return slug ? `${BASE}/details/${slug}` : `${BASE}/subjects`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toItem(book) {
|
||||||
|
return {
|
||||||
|
postId: String(book.id),
|
||||||
|
title: book.title || '(无标题)',
|
||||||
|
cover: book.cover_url || '',
|
||||||
|
date: '',
|
||||||
|
url: pageUrl(book),
|
||||||
|
subtitle: (book.subjects || []).slice(0, 3).join(' · ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pack(books, page) {
|
||||||
|
const start = (page - 1) * PAGE_SIZE;
|
||||||
|
return {
|
||||||
|
items: books.slice(start, start + PAGE_SIZE).map(toItem),
|
||||||
|
maxPage: Math.max(1, Math.ceil(books.length / PAGE_SIZE)),
|
||||||
|
page
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(book, keyword) {
|
||||||
|
const tokens = String(keyword || '').trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (!tokens.length) return true;
|
||||||
|
const text = [
|
||||||
|
book.title,
|
||||||
|
...(book.subjects || []),
|
||||||
|
...(book.subject_categories || [])
|
||||||
|
].filter(Boolean).join(' ').toLocaleLowerCase();
|
||||||
|
return tokens.every((token) => text.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorsOf(book) {
|
||||||
|
return (book.authors || []).map((author) => {
|
||||||
|
if (typeof author === 'string') return author;
|
||||||
|
return author && author.value && author.value.name
|
||||||
|
? author.value.name
|
||||||
|
: (author && author.name) || '';
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectNames(value) {
|
||||||
|
const items = Array.isArray(value) ? value : (value ? [value] : []);
|
||||||
|
return items.map((item) => (
|
||||||
|
typeof item === 'string' ? item : (item && (item.subject_name || item.name)) || ''
|
||||||
|
)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bookDetail(postId) {
|
||||||
|
const id = validateId(postId);
|
||||||
|
const book = await getJson(`${DETAIL_BASE}/${id}/`);
|
||||||
|
if (!book || !book.id) throw new Error('未找到该 OpenStax 教材');
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailUrl(book) {
|
||||||
|
return (book.meta && book.meta.html_url) || `${BASE}/details/books/${book.meta && book.meta.slug || book.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function licenseLabel(book) {
|
||||||
|
return [book.license_name, book.license_version].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeName(title) {
|
||||||
|
return String(title || 'OpenStax 教材').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'openstax',
|
||||||
|
name: 'OpenStax 开放教材',
|
||||||
|
supportsSearch: true,
|
||||||
|
|
||||||
|
async list(page) {
|
||||||
|
page = clampPage(page);
|
||||||
|
return pack(await loadCatalog(), page);
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(keyword, page) {
|
||||||
|
page = clampPage(page);
|
||||||
|
const books = (await loadCatalog()).filter((book) => matches(book, keyword));
|
||||||
|
return pack(books, page);
|
||||||
|
},
|
||||||
|
|
||||||
|
async detail(postId) {
|
||||||
|
const book = await bookDetail(postId);
|
||||||
|
const subjects = [
|
||||||
|
...subjectNames(book.book_subjects),
|
||||||
|
...subjectNames(book.book_categories)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
postId: String(book.id),
|
||||||
|
title: book.title || '(无标题)',
|
||||||
|
cover: book.cover_url || '',
|
||||||
|
authors: authorsOf(book),
|
||||||
|
date: String(book.publish_date || '').slice(0, 10),
|
||||||
|
tags: [
|
||||||
|
...subjects.slice(0, 4).map((subject) => `主题:${subject}`),
|
||||||
|
licenseLabel(book) ? `许可:${licenseLabel(book)}` : '',
|
||||||
|
book.digital_isbn_13 ? `ISBN:${book.digital_isbn_13}` : ''
|
||||||
|
].filter(Boolean),
|
||||||
|
brief: stripTags(book.description || ''),
|
||||||
|
url: detailUrl(book),
|
||||||
|
links: [
|
||||||
|
{ name: 'OpenStax 页', url: detailUrl(book) },
|
||||||
|
...(book.webview_link || book.webview_rex_link
|
||||||
|
? [{ name: '在线阅读', url: book.webview_link || book.webview_rex_link }]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async download(postId) {
|
||||||
|
const book = await bookDetail(postId);
|
||||||
|
const files = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [url, label] of [
|
||||||
|
[book.pdf_url, 'PDF'],
|
||||||
|
[book.high_resolution_pdf_url, '高清 PDF']
|
||||||
|
]) {
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
files.push({
|
||||||
|
name: `${safeName(book.title)}${label === '高清 PDF' ? '-高清' : ''}.pdf`,
|
||||||
|
link: url,
|
||||||
|
format: 'PDF'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const links = [{ name: 'OpenStax 页', url: detailUrl(book) }];
|
||||||
|
if (book.webview_link || book.webview_rex_link) {
|
||||||
|
links.push({ name: '在线阅读', url: book.webview_link || book.webview_rex_link });
|
||||||
|
}
|
||||||
|
if (book.license_url) links.push({ name: '许可说明', url: book.license_url });
|
||||||
|
return { files, links };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
const { fetchJson, clampPage, stripTags } = require('./http');
|
||||||
|
|
||||||
|
const BASE = 'https://open.umn.edu/opentextbooks';
|
||||||
|
|
||||||
|
function getJson(url) {
|
||||||
|
return fetchJson(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateId(postId) {
|
||||||
|
const id = String(postId || '');
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error('无效的开放教材 ID');
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapBook(value) {
|
||||||
|
return value && value.data && !Array.isArray(value.data) ? value.data : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contributorsOf(book) {
|
||||||
|
return (book.contributors || []).map((person) => {
|
||||||
|
if (!person) return '';
|
||||||
|
if (person.corporate) return person.title || person.name || '';
|
||||||
|
return [person.first_name, person.middle_name, person.last_name].filter(Boolean).join(' ');
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageUrl(book) {
|
||||||
|
return book.url || `${BASE}/textbooks/${book.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toItem(book) {
|
||||||
|
return {
|
||||||
|
postId: String(book.id),
|
||||||
|
title: book.title || '(无标题)',
|
||||||
|
cover: book.cover_url || book.cover || '',
|
||||||
|
date: book.copyright_year ? String(book.copyright_year) : '',
|
||||||
|
url: pageUrl(book),
|
||||||
|
subtitle: contributorsOf(book).slice(0, 3).join(', ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pack(response, page) {
|
||||||
|
const links = response && response.links || {};
|
||||||
|
return {
|
||||||
|
items: (response && response.data || []).map(toItem),
|
||||||
|
maxPage: Math.max(1, Number(links.total_pages) || page),
|
||||||
|
page
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bookDetail(postId) {
|
||||||
|
const id = validateId(postId);
|
||||||
|
const book = unwrapBook(await getJson(`${BASE}/textbooks/${id}.json`));
|
||||||
|
if (!book || !book.id) throw new Error('未找到该开放教材');
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(value) {
|
||||||
|
return String(value || '资源').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionOf(type) {
|
||||||
|
const value = formatLabel(type);
|
||||||
|
if (value.includes('EPUB')) return 'epub';
|
||||||
|
if (value.includes('PDF')) return 'pdf';
|
||||||
|
if (value.includes('MOBI') || value.includes('KINDLE')) return 'mobi';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectFile(format) {
|
||||||
|
if (!format || !format.url) return false;
|
||||||
|
const ext = extensionOf(format.type);
|
||||||
|
if (!ext) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(format.url);
|
||||||
|
return new RegExp(`\\.${ext}(?:$|[?#])`, 'i').test(url.pathname + url.search + url.hash);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeName(title) {
|
||||||
|
return String(title || '开放教材').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'opentextbook',
|
||||||
|
name: '开放教材图书馆',
|
||||||
|
supportsSearch: true,
|
||||||
|
|
||||||
|
async list(page) {
|
||||||
|
page = clampPage(page);
|
||||||
|
return pack(await getJson(`${BASE}/textbooks.json?page=${page}`), page);
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(keyword, page) {
|
||||||
|
page = clampPage(page);
|
||||||
|
const query = String(keyword || '').trim();
|
||||||
|
const suffix = query ? `?q=${encodeURIComponent(query)}&page=${page}` : `?page=${page}`;
|
||||||
|
return pack(await getJson(`${BASE}/textbooks.json${suffix}`), page);
|
||||||
|
},
|
||||||
|
|
||||||
|
async detail(postId) {
|
||||||
|
const book = await bookDetail(postId);
|
||||||
|
return {
|
||||||
|
postId: String(book.id),
|
||||||
|
title: book.title || '(无标题)',
|
||||||
|
cover: book.cover_url || book.cover || '',
|
||||||
|
authors: contributorsOf(book),
|
||||||
|
date: book.copyright_year ? String(book.copyright_year) : '',
|
||||||
|
tags: [
|
||||||
|
book.edition_statement ? `版本:${book.edition_statement}` : '',
|
||||||
|
book.license ? `许可:${book.license}` : '',
|
||||||
|
book.language ? `语言:${book.language}` : '',
|
||||||
|
...(book.subjects || []).slice(0, 4).map((subject) => (
|
||||||
|
subject && subject.name ? `主题:${subject.name}` : ''
|
||||||
|
))
|
||||||
|
].filter(Boolean),
|
||||||
|
brief: stripTags(book.description || ''),
|
||||||
|
url: pageUrl(book),
|
||||||
|
links: [{ name: '开放教材图书馆页', url: pageUrl(book) }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async download(postId) {
|
||||||
|
const book = await bookDetail(postId);
|
||||||
|
const files = [];
|
||||||
|
const links = [{ name: '开放教材图书馆页', url: pageUrl(book) }];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const format of book.formats || []) {
|
||||||
|
if (!format || !format.url || seen.has(format.url)) continue;
|
||||||
|
seen.add(format.url);
|
||||||
|
const label = formatLabel(format.type);
|
||||||
|
const ext = extensionOf(label);
|
||||||
|
if (isDirectFile(format)) {
|
||||||
|
files.push({
|
||||||
|
name: `${safeName(book.title)}.${ext}`,
|
||||||
|
link: format.url,
|
||||||
|
format: label
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
links.push({ name: `${label} 获取页`, url: format.url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files, links };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = require('./wikisource').create({
|
||||||
|
id: 'wikisource-en',
|
||||||
|
name: '英文维基文库',
|
||||||
|
lang: 'en',
|
||||||
|
label: '英文'
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = require('./wikisource').create({
|
||||||
|
id: 'wikisource-zh',
|
||||||
|
name: '中文维基文库',
|
||||||
|
lang: 'zh',
|
||||||
|
label: '中文'
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
const { fetchJson, clampPage, stripTags } = require('./http');
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function create({ id, name, lang, label }) {
|
||||||
|
const base = `https://${lang}.wikisource.org`;
|
||||||
|
const pageTokens = new Map([[1, '']]);
|
||||||
|
|
||||||
|
function apiUrl(params) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
...params,
|
||||||
|
format: 'json',
|
||||||
|
formatversion: '2'
|
||||||
|
});
|
||||||
|
return `${base}/w/api.php?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJson(params) {
|
||||||
|
return fetchJson(apiUrl(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateId(postId) {
|
||||||
|
const idValue = String(postId || '');
|
||||||
|
if (!/^\d+$/.test(idValue)) throw new Error(`无效的${label}维基文库 ID`);
|
||||||
|
return idValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toItem(page) {
|
||||||
|
return {
|
||||||
|
postId: String(page.pageid),
|
||||||
|
title: page.title || '(无标题)',
|
||||||
|
cover: page.thumbnail && page.thumbnail.source || '',
|
||||||
|
date: '',
|
||||||
|
url: page.fullurl || `${base}/?curid=${page.pageid}`,
|
||||||
|
subtitle: label
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchListPage(page) {
|
||||||
|
let nearest = 1;
|
||||||
|
for (const known of pageTokens.keys()) {
|
||||||
|
if (known <= page && known > nearest) nearest = known;
|
||||||
|
}
|
||||||
|
let token = pageTokens.get(nearest) || '';
|
||||||
|
let result = null;
|
||||||
|
for (let current = nearest; current <= page; current++) {
|
||||||
|
const params = {
|
||||||
|
action: 'query',
|
||||||
|
list: 'allpages',
|
||||||
|
apnamespace: '0',
|
||||||
|
apfilterredir: 'nonredirects',
|
||||||
|
aplimit: String(PAGE_SIZE)
|
||||||
|
};
|
||||||
|
if (token) params.apcontinue = token;
|
||||||
|
result = await getJson(params);
|
||||||
|
const next = result && result.continue && result.continue.apcontinue;
|
||||||
|
if (next) pageTokens.set(current + 1, next);
|
||||||
|
if (current === page || !next) break;
|
||||||
|
token = next;
|
||||||
|
}
|
||||||
|
return result || { query: { allpages: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageDetail(postId) {
|
||||||
|
const pageId = validateId(postId);
|
||||||
|
const response = await getJson({
|
||||||
|
action: 'query',
|
||||||
|
prop: 'extracts|pageimages|info',
|
||||||
|
pageids: pageId,
|
||||||
|
exintro: '1',
|
||||||
|
explaintext: '1',
|
||||||
|
piprop: 'thumbnail',
|
||||||
|
pithumbsize: '300',
|
||||||
|
inprop: 'url'
|
||||||
|
});
|
||||||
|
const page = response && response.query && response.query.pages && response.query.pages[0];
|
||||||
|
if (!page || page.missing) throw new Error(`未找到该${label}维基文库页面`);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileName(title) {
|
||||||
|
return String(title || '维基文库作品').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportUrl(title, format) {
|
||||||
|
const query = new URLSearchParams({ lang, page: title, format });
|
||||||
|
return `https://ws-export.wmcloud.org/?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(page) {
|
||||||
|
page = clampPage(page);
|
||||||
|
const response = await fetchListPage(page);
|
||||||
|
const items = response && response.query && response.query.allpages || [];
|
||||||
|
return {
|
||||||
|
items: items.map(toItem),
|
||||||
|
maxPage: response && response.continue ? page + 1 : page,
|
||||||
|
page
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
supportsSearch: true,
|
||||||
|
|
||||||
|
list,
|
||||||
|
|
||||||
|
async search(keyword, page) {
|
||||||
|
page = Math.min(clampPage(page), 500);
|
||||||
|
const query = String(keyword || '').trim();
|
||||||
|
if (!query) return list(page);
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
const response = await getJson({
|
||||||
|
action: 'query',
|
||||||
|
list: 'search',
|
||||||
|
srsearch: query,
|
||||||
|
srnamespace: '0',
|
||||||
|
srlimit: String(PAGE_SIZE),
|
||||||
|
sroffset: String(offset),
|
||||||
|
srprop: 'size|wordcount|timestamp|snippet'
|
||||||
|
});
|
||||||
|
const search = response && response.query && response.query.search || [];
|
||||||
|
const total = response && response.query && response.query.searchinfo
|
||||||
|
? Number(response.query.searchinfo.totalhits) || 0
|
||||||
|
: search.length;
|
||||||
|
return {
|
||||||
|
items: search.map(toItem),
|
||||||
|
maxPage: Math.max(1, Math.ceil(Math.min(total, 10000) / PAGE_SIZE)),
|
||||||
|
page
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async detail(postId) {
|
||||||
|
const page = await pageDetail(postId);
|
||||||
|
return {
|
||||||
|
postId: String(page.pageid),
|
||||||
|
title: page.title || '(无标题)',
|
||||||
|
cover: page.thumbnail && page.thumbnail.source || '',
|
||||||
|
authors: [],
|
||||||
|
date: '',
|
||||||
|
tags: [`语言:${label}`, '许可:以原始页面标注为准'],
|
||||||
|
brief: stripTags(page.extract || ''),
|
||||||
|
url: page.fullurl || `${base}/?curid=${page.pageid}`,
|
||||||
|
links: [{ name: `${label}维基文库页`, url: page.fullurl || `${base}/?curid=${page.pageid}` }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async download(postId) {
|
||||||
|
const page = await pageDetail(postId);
|
||||||
|
const title = page.title || `wikisource-${page.pageid}`;
|
||||||
|
const safeTitle = fileName(title);
|
||||||
|
return {
|
||||||
|
files: [
|
||||||
|
{ name: `${safeTitle}.epub`, link: exportUrl(title, 'epub'), format: 'EPUB' },
|
||||||
|
{ name: `${safeTitle}.pdf`, link: exportUrl(title, 'pdf'), format: 'PDF' }
|
||||||
|
],
|
||||||
|
links: [{
|
||||||
|
name: `${label}维基文库页`,
|
||||||
|
url: page.fullurl || `${base}/?curid=${page.pageid}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create };
|
||||||
@@ -45,6 +45,7 @@ document.querySelectorAll('.tab').forEach((t) => {
|
|||||||
t.onclick = () => switchTab(t.dataset.tab);
|
t.onclick = () => switchTab(t.dataset.tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
DownloadCenter.init();
|
||||||
Browse.init();
|
Browse.init();
|
||||||
Library.init();
|
Library.init();
|
||||||
Notes.init();
|
Notes.init();
|
||||||
@@ -219,6 +220,77 @@ $('semanticKeyClearBtn').onclick = async () => {
|
|||||||
|
|
||||||
refreshSemanticKeyStatus();
|
refreshSemanticKeyStatus();
|
||||||
|
|
||||||
|
let orphanFound = null;
|
||||||
|
|
||||||
|
function describeOrphans(data) {
|
||||||
|
const notes = data.notes || [];
|
||||||
|
const annotations = data.annotations || [];
|
||||||
|
if (!notes.length && !annotations.length) return '没有残留数据';
|
||||||
|
const parts = [];
|
||||||
|
if (notes.length) {
|
||||||
|
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
|
||||||
|
parts.push(`${notes.length} 本已移除书籍留有阅读资料(含 ${noteTotal} 条笔记)`);
|
||||||
|
}
|
||||||
|
if (annotations.length) {
|
||||||
|
const mb = (data.totalBytes || 0) / 1024 / 1024;
|
||||||
|
const size = mb >= 0.1 ? `${mb.toFixed(1)} MB` : `${Math.round((data.totalBytes || 0) / 1024)} KB`;
|
||||||
|
parts.push(`${annotations.length} 份孤立批注(约 ${size})`);
|
||||||
|
}
|
||||||
|
return parts.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('orphanScanBtn').onclick = async () => {
|
||||||
|
const btn = $('orphanScanBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '检查中...';
|
||||||
|
const r = await window.api.reader.orphanReport();
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '检查';
|
||||||
|
if (!r || !r.ok) {
|
||||||
|
$('orphanStatus').textContent = `检查失败:${(r && r.error) || '未知错误'}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
orphanFound = r.data;
|
||||||
|
$('orphanStatus').textContent = describeOrphans(r.data);
|
||||||
|
const hasAny = (r.data.notes || []).length > 0 || (r.data.annotations || []).length > 0;
|
||||||
|
$('orphanPurgeBtn').classList.toggle('hidden', !hasAny);
|
||||||
|
};
|
||||||
|
|
||||||
|
$('orphanPurgeBtn').onclick = async () => {
|
||||||
|
if (!orphanFound) return;
|
||||||
|
const notes = orphanFound.notes || [];
|
||||||
|
const annotations = orphanFound.annotations || [];
|
||||||
|
// 笔记是用户创作,删除不可撤销,必须让用户单独确认这一项
|
||||||
|
const lines = [];
|
||||||
|
if (annotations.length) lines.push(`<li>${annotations.length} 份孤立批注</li>`);
|
||||||
|
if (notes.length) {
|
||||||
|
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
|
||||||
|
lines.push(`<li>${notes.length} 本已移除书籍的阅读资料,含 <b>${noteTotal} 条笔记</b></li>`);
|
||||||
|
}
|
||||||
|
const choice = await openModal('清理残留阅读资料', `
|
||||||
|
<p>检查到:</p>
|
||||||
|
<ul class="library-bulk-preview">${lines.join('')}</ul>
|
||||||
|
<p style="margin-top:8px"><label><input type="checkbox" id="orphanDelAnnotations" checked /> 清理孤立批注</label></p>
|
||||||
|
${notes.length ? `<p style="margin-top:6px"><label><input type="checkbox" id="orphanDelNotes" /> 同时删除笔记、书签与进度</label></p>
|
||||||
|
<p class="muted" style="margin-top:6px">这些笔记目前仍可在「我的笔记」中查看,删除后无法恢复。</p>` : ''}
|
||||||
|
`, () => ({
|
||||||
|
annotations: !!(document.getElementById('orphanDelAnnotations') || {}).checked,
|
||||||
|
notes: !!(document.getElementById('orphanDelNotes') || {}).checked
|
||||||
|
}));
|
||||||
|
if (!choice) return;
|
||||||
|
if (!choice.annotations && !choice.notes) return;
|
||||||
|
const r = await window.api.reader.purgeOrphans(choice);
|
||||||
|
if (!r || !r.ok) {
|
||||||
|
await confirmModal('清理失败', (r && r.error) || '未知错误');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const done = r.data || {};
|
||||||
|
$('orphanStatus').textContent =
|
||||||
|
`已清理 ${done.annotationsRemoved || 0} 份批注、${done.notesRemoved || 0} 本阅读资料`;
|
||||||
|
$('orphanPurgeBtn').classList.add('hidden');
|
||||||
|
orphanFound = null;
|
||||||
|
};
|
||||||
|
|
||||||
const AI_PROTOCOL_INFO = {
|
const AI_PROTOCOL_INFO = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
label: 'Anthropic',
|
label: 'Anthropic',
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
(() => {
|
||||||
|
const tasks = [];
|
||||||
|
const MAX_HISTORY = 30;
|
||||||
|
let seq = 0;
|
||||||
|
let button = null;
|
||||||
|
let badge = null;
|
||||||
|
let panel = null;
|
||||||
|
let list = null;
|
||||||
|
let summary = null;
|
||||||
|
let clearButton = null;
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const bytes = Math.max(0, Number(value) || 0);
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(task) {
|
||||||
|
return ['pending', 'running', 'pausing', 'deleting'].includes(task.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnfinished(task) {
|
||||||
|
return isActive(task) || task.status === 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(task) {
|
||||||
|
if (task.status === 'pending') return '准备下载…';
|
||||||
|
if (task.status === 'pausing') return '正在暂停…';
|
||||||
|
if (task.status === 'paused') {
|
||||||
|
return task.receivedBytes ? `已暂停 · ${formatBytes(task.receivedBytes)}` : '已暂停';
|
||||||
|
}
|
||||||
|
if (task.status === 'deleting') return '正在删除…';
|
||||||
|
if (task.status === 'failed') return task.error || '下载失败';
|
||||||
|
if (task.status === 'canceled') return '已取消';
|
||||||
|
if (task.status === 'complete') {
|
||||||
|
return task.receivedBytes ? `下载完成 · ${formatBytes(task.receivedBytes)}` : '下载完成';
|
||||||
|
}
|
||||||
|
if (task.totalBytes && task.percent != null) {
|
||||||
|
return `${Math.round(task.percent * 100)}% · ${formatBytes(task.receivedBytes)} / ${formatBytes(task.totalBytes)}`;
|
||||||
|
}
|
||||||
|
return task.receivedBytes ? `${formatBytes(task.receivedBytes)} 已下载` : '正在连接…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskHtml(task) {
|
||||||
|
const ratio = task.status === 'complete'
|
||||||
|
? 1
|
||||||
|
: (task.percent == null ? null : Math.max(0, Math.min(1, task.percent)));
|
||||||
|
const progressClass = ratio == null && ['pending', 'running', 'pausing'].includes(task.status)
|
||||||
|
? ' indeterminate'
|
||||||
|
: '';
|
||||||
|
const width = ratio == null ? 0 : Math.round(ratio * 100);
|
||||||
|
const book = task.bookTitle && task.bookTitle !== task.name
|
||||||
|
? `<div class="task-center-book">${escapeHtml(task.bookTitle)}</div>`
|
||||||
|
: '';
|
||||||
|
let actions = '';
|
||||||
|
if (task.status === 'complete') {
|
||||||
|
actions = `<div class="task-center-actions">
|
||||||
|
<button data-task-action="open">打开</button>
|
||||||
|
<button data-task-action="reveal">定位</button>
|
||||||
|
<button data-task-action="remove">移除</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (task.status === 'running' || task.status === 'pending') {
|
||||||
|
actions = `<div class="task-center-actions">
|
||||||
|
<button data-task-action="pause">暂停</button>
|
||||||
|
<button data-task-action="delete">删除</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (task.status === 'paused') {
|
||||||
|
actions = `<div class="task-center-actions">
|
||||||
|
<button data-task-action="resume">继续</button>
|
||||||
|
<button data-task-action="delete">删除</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (!isActive(task)) {
|
||||||
|
actions = `<div class="task-center-actions"><button data-task-action="remove">移除</button></div>`;
|
||||||
|
}
|
||||||
|
const state = task.status === 'complete'
|
||||||
|
? '已完成'
|
||||||
|
: (task.status === 'failed'
|
||||||
|
? '失败'
|
||||||
|
: (task.status === 'canceled'
|
||||||
|
? '已取消'
|
||||||
|
: (task.status === 'paused' ? '已暂停' : '下载中')));
|
||||||
|
return `
|
||||||
|
<article class="task-center-item ${task.status}" data-task-id="${escapeHtml(task.id)}">
|
||||||
|
<div class="task-center-item-head">
|
||||||
|
<div class="task-center-name" title="${escapeHtml(task.name)}">${escapeHtml(task.name)}</div>
|
||||||
|
<span class="task-center-state">${state}</span>
|
||||||
|
</div>
|
||||||
|
${book}
|
||||||
|
<div class="task-center-progress${progressClass}">
|
||||||
|
<div class="task-center-progress-fill" style="width:${width}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="task-center-item-foot">
|
||||||
|
<span class="task-center-status" title="${escapeHtml(statusText(task))}">${escapeHtml(statusText(task))}</span>
|
||||||
|
${actions}
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!button) return;
|
||||||
|
const active = tasks.filter(isActive).length;
|
||||||
|
const paused = tasks.filter((task) => task.status === 'paused').length;
|
||||||
|
const unread = tasks.filter((task) => task.unread).length;
|
||||||
|
const count = active + paused + unread;
|
||||||
|
badge.textContent = String(Math.min(99, count));
|
||||||
|
badge.classList.toggle('hidden', count === 0);
|
||||||
|
badge.classList.toggle('has-result', active === 0 && unread > 0);
|
||||||
|
button.title = active
|
||||||
|
? `任务中心,${active} 个下载中`
|
||||||
|
: (paused
|
||||||
|
? `任务中心,${paused} 个已暂停`
|
||||||
|
: (unread ? `任务中心,${unread} 个新结果` : '任务中心'));
|
||||||
|
button.setAttribute('aria-label', button.title);
|
||||||
|
|
||||||
|
const complete = tasks.filter((task) => task.status === 'complete').length;
|
||||||
|
const failed = tasks.filter((task) => task.status === 'failed').length;
|
||||||
|
summary.textContent = active
|
||||||
|
? `${active} 个下载中${paused ? `,${paused} 个已暂停` : ''}${complete ? `,${complete} 个已完成` : ''}`
|
||||||
|
: (tasks.length
|
||||||
|
? `${paused ? `${paused} 个已暂停,` : ''}${complete} 个已完成${failed ? `,${failed} 个失败` : ''}`
|
||||||
|
: '下载任务会显示在这里');
|
||||||
|
list.innerHTML = tasks.length
|
||||||
|
? tasks.map(taskHtml).join('')
|
||||||
|
: '<div class="task-center-empty">暂无下载任务</div>';
|
||||||
|
clearButton.classList.toggle('hidden', !tasks.some((task) => !isUnfinished(task)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPanelOpen(open) {
|
||||||
|
panel.classList.toggle('hidden', !open);
|
||||||
|
button.setAttribute('aria-expanded', String(open));
|
||||||
|
if (open) {
|
||||||
|
tasks.forEach((task) => { task.unread = false; });
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneHistory() {
|
||||||
|
let terminal = tasks.filter((task) => !isUnfinished(task)).length;
|
||||||
|
for (let i = tasks.length - 1; i >= 0 && terminal > MAX_HISTORY; i--) {
|
||||||
|
if (isUnfinished(tasks[i])) continue;
|
||||||
|
tasks.splice(i, 1);
|
||||||
|
terminal--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(task, data, callback) {
|
||||||
|
if (!['pausing', 'deleting'].includes(task.status)) task.status = 'running';
|
||||||
|
task.receivedBytes = Math.max(0, Number(data && data.receivedBytes) || 0);
|
||||||
|
const total = Number(data && data.totalBytes);
|
||||||
|
task.totalBytes = Number.isFinite(total) && total > 0 ? total : null;
|
||||||
|
const ratio = Number(data && data.percent);
|
||||||
|
task.percent = data && data.percent != null && Number.isFinite(ratio)
|
||||||
|
? Math.max(0, Math.min(1, ratio))
|
||||||
|
: null;
|
||||||
|
render();
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
try { callback(data); } catch (e) { /* 页面内进度异常不影响全局任务 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(task, input, onProgress) {
|
||||||
|
task.status = 'running';
|
||||||
|
task.error = '';
|
||||||
|
render();
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await window.api.downloads.run(
|
||||||
|
task.requestId,
|
||||||
|
input.url,
|
||||||
|
input.suggestName,
|
||||||
|
input.entryId,
|
||||||
|
input.extraHeaders,
|
||||||
|
input.meta,
|
||||||
|
(data) => updateProgress(task, data, onProgress)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
result = { ok: false, error: (error && error.message) || String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && result.ok && result.data && result.data.canceled) {
|
||||||
|
task.status = 'canceled';
|
||||||
|
} else if (result && result.ok && result.data && result.data.paused) {
|
||||||
|
task.status = 'paused';
|
||||||
|
task.receivedBytes = Number(result.data.receivedBytes) || task.receivedBytes;
|
||||||
|
task.totalBytes = Number(result.data.totalBytes) || task.totalBytes;
|
||||||
|
task.percent = task.totalBytes ? task.receivedBytes / task.totalBytes : task.percent;
|
||||||
|
task.promise = null;
|
||||||
|
render();
|
||||||
|
return result;
|
||||||
|
} else if (result && result.ok && result.data && result.data.deleted) {
|
||||||
|
const index = tasks.indexOf(task);
|
||||||
|
if (index >= 0) tasks.splice(index, 1);
|
||||||
|
render();
|
||||||
|
return result;
|
||||||
|
} else if (!result || !result.ok) {
|
||||||
|
task.status = 'failed';
|
||||||
|
task.error = (result && result.error) || '下载失败';
|
||||||
|
} else {
|
||||||
|
task.status = 'complete';
|
||||||
|
task.percent = 1;
|
||||||
|
task.path = result.data.path || '';
|
||||||
|
task.receivedBytes = Math.max(task.receivedBytes, Number(result.data.receivedBytes) || 0);
|
||||||
|
if (window.Library) window.Library.markDirty();
|
||||||
|
}
|
||||||
|
task.key = '';
|
||||||
|
task.input = null;
|
||||||
|
task.promise = null;
|
||||||
|
task.unread = panel.classList.contains('hidden');
|
||||||
|
pruneHistory();
|
||||||
|
render();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(input) {
|
||||||
|
const key = String(input && input.key || '');
|
||||||
|
const existing = key && tasks.find((task) => task.key === key && isUnfinished(task));
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'paused') {
|
||||||
|
existing.input.onProgress = input && input.onProgress;
|
||||||
|
existing.promise = run(existing, existing.input, existing.input.onProgress);
|
||||||
|
}
|
||||||
|
return existing.promise || Promise.resolve({ ok: true, data: { paused: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = input && input.meta || {};
|
||||||
|
const task = {
|
||||||
|
id: `download_${Date.now().toString(36)}_${(++seq).toString(36)}`,
|
||||||
|
requestId: `task_${Date.now().toString(36)}_${seq.toString(36)}`,
|
||||||
|
key,
|
||||||
|
name: String(input && input.suggestName || meta.title || '未命名下载'),
|
||||||
|
bookTitle: String(meta.title || ''),
|
||||||
|
status: 'pending',
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
percent: null,
|
||||||
|
error: '',
|
||||||
|
path: '',
|
||||||
|
unread: false,
|
||||||
|
promise: null,
|
||||||
|
input: {
|
||||||
|
url: String(input && input.url || ''),
|
||||||
|
suggestName: String(input && input.suggestName || ''),
|
||||||
|
entryId: input && input.entryId,
|
||||||
|
extraHeaders: input && input.extraHeaders,
|
||||||
|
meta,
|
||||||
|
onProgress: input && input.onProgress
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tasks.unshift(task);
|
||||||
|
pruneHistory();
|
||||||
|
render();
|
||||||
|
task.promise = run(task, task.input, task.input.onProgress);
|
||||||
|
return task.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function taskAction(action, id) {
|
||||||
|
const index = tasks.findIndex((task) => task.id === id);
|
||||||
|
if (index < 0) return;
|
||||||
|
const task = tasks[index];
|
||||||
|
if (action === 'open' && task.path) {
|
||||||
|
const result = await window.api.openPath(task.path);
|
||||||
|
if (!result.ok) await confirmModal('打开失败', result.error || '无法打开该文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'reveal' && task.path) {
|
||||||
|
window.api.showItem(task.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'pause' && ['pending', 'running'].includes(task.status)) {
|
||||||
|
const previousStatus = task.status;
|
||||||
|
task.status = 'pausing';
|
||||||
|
render();
|
||||||
|
const result = await window.api.downloads.pause(task.requestId);
|
||||||
|
if ((!result || !result.ok || !result.data) && task.status === 'pausing') {
|
||||||
|
task.status = previousStatus;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'resume' && task.status === 'paused' && task.input) {
|
||||||
|
task.promise = run(task, task.input, task.input.onProgress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'delete' && isUnfinished(task)) {
|
||||||
|
const previousStatus = task.status;
|
||||||
|
task.status = 'deleting';
|
||||||
|
render();
|
||||||
|
const result = await window.api.downloads.delete(task.requestId);
|
||||||
|
if ((!result || !result.ok || !result.data) && task.status === 'deleting') {
|
||||||
|
task.status = previousStatus;
|
||||||
|
task.error = (result && result.error) || '';
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!task.promise) {
|
||||||
|
tasks.splice(index, 1);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove' && !isUnfinished(task)) {
|
||||||
|
tasks.splice(index, 1);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
button = $('taskCenterBtn');
|
||||||
|
badge = $('taskCenterBadge');
|
||||||
|
panel = $('taskCenterPanel');
|
||||||
|
list = $('taskCenterList');
|
||||||
|
summary = $('taskCenterSummary');
|
||||||
|
clearButton = $('taskCenterClear');
|
||||||
|
|
||||||
|
button.onclick = () => setPanelOpen(panel.classList.contains('hidden'));
|
||||||
|
list.onclick = (event) => {
|
||||||
|
const actionButton = event.target.closest('[data-task-action]');
|
||||||
|
const item = event.target.closest('[data-task-id]');
|
||||||
|
if (actionButton && item) taskAction(actionButton.dataset.taskAction, item.dataset.taskId);
|
||||||
|
};
|
||||||
|
clearButton.onclick = () => {
|
||||||
|
for (let i = tasks.length - 1; i >= 0; i--) {
|
||||||
|
if (!isUnfinished(tasks[i])) tasks.splice(i, 1);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', (event) => {
|
||||||
|
if (!panel.classList.contains('hidden') && !event.target.closest('.task-center-wrap')) {
|
||||||
|
setPanelOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && !panel.classList.contains('hidden')) setPanelOpen(false);
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DownloadCenter = { init, start };
|
||||||
|
})();
|
||||||
@@ -25,6 +25,25 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="titlebar-spacer"></div>
|
<div class="titlebar-spacer"></div>
|
||||||
<div class="titlebar-controls">
|
<div class="titlebar-controls">
|
||||||
|
<div class="task-center-wrap">
|
||||||
|
<button id="taskCenterBtn" class="win-btn task-center-btn" title="任务中心" aria-label="任务中心" aria-expanded="false" aria-controls="taskCenterPanel">
|
||||||
|
<svg class="titlebar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"></path>
|
||||||
|
<path d="M8 9h8M8 13h5M8 17h3"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="taskCenterBadge" class="task-center-badge hidden">0</span>
|
||||||
|
</button>
|
||||||
|
<section id="taskCenterPanel" class="task-center-panel hidden" aria-label="下载任务">
|
||||||
|
<div class="task-center-head">
|
||||||
|
<div>
|
||||||
|
<div class="task-center-title">任务中心</div>
|
||||||
|
<div id="taskCenterSummary" class="task-center-summary">下载任务会显示在这里</div>
|
||||||
|
</div>
|
||||||
|
<button id="taskCenterClear" class="task-center-clear hidden">清除已结束</button>
|
||||||
|
</div>
|
||||||
|
<div id="taskCenterList" class="task-center-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<button id="uiThemeBtn" class="win-btn ui-theme-btn" title="切换到明亮主题" aria-label="切换到明亮主题">
|
<button id="uiThemeBtn" class="win-btn ui-theme-btn" title="切换到明亮主题" aria-label="切换到明亮主题">
|
||||||
<svg class="titlebar-icon ui-theme-sun" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="titlebar-icon ui-theme-sun" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"></path>
|
<circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"></path>
|
||||||
@@ -49,15 +68,17 @@
|
|||||||
<h2>书架</h2>
|
<h2>书架</h2>
|
||||||
<button id="addShelfBtn" class="notes-icon-btn" title="新建书架" aria-label="新建书架">+</button>
|
<button id="addShelfBtn" class="notes-icon-btn" title="新建书架" aria-label="新建书架">+</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="library-filter active" data-shelf="">全部书籍</button>
|
<div class="library-filter-list">
|
||||||
<button class="library-filter" data-shelf="__uncategorized__">未分类</button>
|
<button class="library-filter active" data-shelf="">全部书籍</button>
|
||||||
<div id="libraryShelfList"></div>
|
<button class="library-filter" data-shelf="__uncategorized__">未分类</button>
|
||||||
|
<div id="libraryShelfList" class="library-filter-list"></div>
|
||||||
|
</div>
|
||||||
<div class="library-sidebar-section">
|
<div class="library-sidebar-section">
|
||||||
<div class="library-sidebar-section-head">
|
<div class="library-sidebar-section-head">
|
||||||
<h3>标签</h3>
|
<h3>标签</h3>
|
||||||
<button id="addTagBtn" class="notes-icon-btn" title="新建标签" aria-label="新建标签">+</button>
|
<button id="addTagBtn" class="notes-icon-btn" title="新建标签" aria-label="新建标签">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="libraryTagList" class="library-tag-list"></div>
|
<div id="libraryTagList" class="library-filter-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="library-content">
|
<div class="library-content">
|
||||||
@@ -69,9 +90,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<span id="libStatus" class="status-bar"></span>
|
<span id="libStatus" class="status-bar"></span>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
<button id="librarySelectModeBtn" class="tb-btn ghost" aria-pressed="false">选择</button>
|
||||||
<button id="rescanBtn" class="tb-btn ghost">重新扫描</button>
|
<button id="rescanBtn" class="tb-btn ghost">重新扫描</button>
|
||||||
<button id="addLocalBtn" class="tb-btn">+ 添加本地</button>
|
<button id="addLocalBtn" class="tb-btn">+ 添加本地</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="librarySelectionBar" class="library-selection-bar hidden">
|
||||||
|
<label class="library-select-all">
|
||||||
|
<input type="checkbox" id="librarySelectAll" />
|
||||||
|
<span id="librarySelectAllLabel">全选</span>
|
||||||
|
</label>
|
||||||
|
<span id="librarySelectionCount" class="status-bar">未选择</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="libraryBulkOrganizeBtn" class="tb-btn ghost" disabled>批量整理</button>
|
||||||
|
<button id="libraryBulkRemoveBtn" class="tb-btn danger" disabled>批量移除</button>
|
||||||
|
<button id="librarySelectExitBtn" class="tb-btn ghost">退出选择</button>
|
||||||
|
</div>
|
||||||
<div id="libGrid" class="grid"></div>
|
<div id="libGrid" class="grid"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,6 +233,17 @@
|
|||||||
<button id="proxySaveBtn" class="tb-btn">保存</button>
|
<button id="proxySaveBtn" class="tb-btn">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-item">
|
||||||
|
<div class="settings-item-info">
|
||||||
|
<div class="settings-item-label">残留阅读资料</div>
|
||||||
|
<div class="settings-item-desc">书籍移除时默认保留笔记、批注与进度。笔记仍可在「我的笔记」中查看,批注则没有查看入口。</div>
|
||||||
|
<div class="settings-path" id="orphanStatus">未检查</div>
|
||||||
|
</div>
|
||||||
|
<button id="orphanScanBtn" class="tb-btn">检查</button>
|
||||||
|
<button id="orphanPurgeBtn" class="tb-btn danger hidden">清理</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-item">
|
<div class="settings-item">
|
||||||
<div class="settings-item-info">
|
<div class="settings-item-info">
|
||||||
@@ -288,11 +332,11 @@
|
|||||||
<div class="about-formats">
|
<div class="about-formats">
|
||||||
<div>
|
<div>
|
||||||
<span class="about-format-label">内置阅读</span>
|
<span class="about-format-label">内置阅读</span>
|
||||||
<span>PDF、EPUB、MOBI、AZW、AZW3</span>
|
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、MD</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="about-format-label">书库导入与管理</span>
|
<span class="about-format-label">书库导入与管理</span>
|
||||||
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、DJVU、FB2、CBZ、CBR</span>
|
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、MD、DJVU、FB2、CBZ、CBR</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-item-desc">
|
<div class="settings-item-desc">
|
||||||
MOBI、AZW 与 AZW3 由 Foliate 解析,支持无 DRM 的 MOBI/KF7/KF8 内容;DRM、KFX 与损坏文件可改用系统应用打开。
|
MOBI、AZW 与 AZW3 由 Foliate 解析,支持无 DRM 的 MOBI/KF7/KF8 内容;DRM、KFX 与损坏文件可改用系统应用打开。
|
||||||
@@ -317,6 +361,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="util.js"></script>
|
<script src="util.js"></script>
|
||||||
|
<script src="download-center.js"></script>
|
||||||
<script src="views/browse.js"></script>
|
<script src="views/browse.js"></script>
|
||||||
<script src="views/library.js"></script>
|
<script src="views/library.js"></script>
|
||||||
<script src="vendor/quill/quill.js"></script>
|
<script src="vendor/quill/quill.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/* 笔记独立窗口。整窗给编辑区,画布不再受模态尺寸限制。 */
|
||||||
|
|
||||||
|
.note-window-body {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签条与阅读器同构。note.html 不加载 reader.css,所以这里要自带一份,
|
||||||
|
class 名保持一致,改样式时两处要一起改。 */
|
||||||
|
.doctabs {
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.doctabs-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
.doctabs-list::-webkit-scrollbar { height: 0; }
|
||||||
|
.doctab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 4px 0;
|
||||||
|
padding: 0 8px 0 14px;
|
||||||
|
gap: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* --hover-bg-soft 只在 reader.css 里定义,这里必须用 style.css 有的变量,
|
||||||
|
否则 hover 背景静默失效 */
|
||||||
|
.doctab:hover { background: var(--hover-bg); color: var(--text); }
|
||||||
|
.doctab.active { background: var(--bg-card); border-color: var(--line); color: var(--text); }
|
||||||
|
.doctab-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.doctab.active .doctab-name { color: var(--accent-bright); font-weight: 600; }
|
||||||
|
.doctab-fmt {
|
||||||
|
padding: 0 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
/* 未保存标记:标签上必须看得见,否则关窗时才发现有改动 */
|
||||||
|
.doctab-dirty {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-bright);
|
||||||
|
}
|
||||||
|
.doctab-close {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.doctab-close:hover { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
/* 每个标签一个视图,非活跃的整块隐藏。用 display:none 而不是移出 DOM:
|
||||||
|
编辑器实例要留着,切回来不必重建,也不会丢未保存内容。 */
|
||||||
|
.note-tab-views {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.note-tab-view {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.note-tab-view.inactive { display: none; }
|
||||||
|
|
||||||
|
.note-dirty-box { width: 420px; max-width: 92vw; }
|
||||||
|
|
||||||
|
.note-dirty-notice {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px 18px 16px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-loading,
|
||||||
|
.note-window-error {
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-form {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-title {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-title:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: var(--hover-strong);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑区吃满剩余空间。min-height:0 缺一层,flex 子项就会被内容顶高、画布溢出窗口 */
|
||||||
|
.note-window-editor {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-editor > .mixed-note-editor,
|
||||||
|
.note-window-editor .mixed-note-canvas,
|
||||||
|
.note-window-editor .mixed-note-text,
|
||||||
|
.note-window-editor .rich-note-editor {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-editor .ql-container {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-quote {
|
||||||
|
max-height: 88px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-meta label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 笔记本名过长会把整行顶宽,须同时限宽并允许收缩到 0 */
|
||||||
|
.note-window-meta select,
|
||||||
|
.note-window-meta input[type="text"] {
|
||||||
|
height: 28px;
|
||||||
|
max-width: 260px;
|
||||||
|
min-width: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-meta select:focus,
|
||||||
|
.note-window-meta input[type="text"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-tags input { width: 220px; }
|
||||||
|
|
||||||
|
.note-window-pin { cursor: pointer; }
|
||||||
|
|
||||||
|
.note-window-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-status {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-window-status.error { color: var(--danger); }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.note-window { padding: 10px 12px 12px; }
|
||||||
|
.note-window-tags input { width: 140px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-ui-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; script-src 'self'" />
|
||||||
|
<title>PeopleLib</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<link rel="stylesheet" href="vendor/quill/quill.snow.css" />
|
||||||
|
<link rel="stylesheet" href="rich-note.css" />
|
||||||
|
<link rel="stylesheet" href="note-window.css" />
|
||||||
|
</head>
|
||||||
|
<body class="note-window-body">
|
||||||
|
<div class="titlebar">
|
||||||
|
<div class="titlebar-left">
|
||||||
|
<span class="brand">
|
||||||
|
<img class="brand-logo brand-logo-dark" src="../../icons/dist/dark/icon-32.png" alt="" />
|
||||||
|
<img class="brand-logo brand-logo-light" src="../../icons/dist/light/icon-32.png" alt="" />
|
||||||
|
<span>笔记</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="titlebar-spacer"></div>
|
||||||
|
<div class="titlebar-controls">
|
||||||
|
<button id="uiThemeBtn" class="win-btn ui-theme-btn" title="切换到明亮主题" aria-label="切换到明亮主题">
|
||||||
|
<svg class="titlebar-icon ui-theme-sun" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="titlebar-icon ui-theme-moon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="minBtn" class="win-btn" title="最小化">─</button>
|
||||||
|
<button id="maxBtn" class="win-btn" title="最大化">□</button>
|
||||||
|
<button id="closeBtn" class="win-btn win-close" title="关闭">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doctabs">
|
||||||
|
<div id="noteTabsList" class="doctabs-list" role="tablist"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="note-window">
|
||||||
|
<div id="noteWindowLoading" class="note-window-loading">正在加载笔记…</div>
|
||||||
|
|
||||||
|
<div id="noteWindowError" class="note-window-error hidden" role="alert"></div>
|
||||||
|
|
||||||
|
<div id="noteWindowEmpty" class="note-window-loading hidden">没有打开的笔记</div>
|
||||||
|
|
||||||
|
<div id="noteTabViews" class="note-tab-views"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<template id="noteTabTemplate">
|
||||||
|
<form class="note-window-form" autocomplete="off">
|
||||||
|
<div class="note-window-head">
|
||||||
|
<input class="note-window-title" type="text" maxlength="300" placeholder="标题(可选)" />
|
||||||
|
<span class="note-window-badge"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-window-editor"></div>
|
||||||
|
|
||||||
|
<blockquote class="note-window-quote hidden"></blockquote>
|
||||||
|
|
||||||
|
<div class="note-window-meta">
|
||||||
|
<label>
|
||||||
|
<span>笔记本</span>
|
||||||
|
<select class="note-window-collection">
|
||||||
|
<option value="">未分类</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="note-window-tags">
|
||||||
|
<span>标签</span>
|
||||||
|
<input class="note-window-tags-input" type="text" placeholder="用逗号分隔" />
|
||||||
|
</label>
|
||||||
|
<label class="note-window-pin">
|
||||||
|
<input class="note-window-pinned" type="checkbox" /> 置顶
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-window-actions">
|
||||||
|
<span class="note-window-status" role="status"></span>
|
||||||
|
<button class="tb-btn note-window-save" type="submit">保存笔记</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div id="noteDirtyModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="noteDirtyTitle">
|
||||||
|
<div class="modal-box note-dirty-box">
|
||||||
|
<div id="noteDirtyTitle" class="modal-title">这条笔记还没保存?</div>
|
||||||
|
<p id="noteDirtyNotice" class="note-dirty-notice">关闭后未保存的修改会丢失。</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="noteDirtyCancelBtn" class="tb-btn ghost" type="button">取消</button>
|
||||||
|
<button id="noteDirtyDiscardBtn" class="tb-btn danger" type="button">放弃修改</button>
|
||||||
|
<button id="noteDirtySaveBtn" class="tb-btn" type="button">保存并关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="vendor/jszip.min.js"></script>
|
||||||
|
<script src="vendor/quill/quill.js"></script>
|
||||||
|
<script src="vendor/jspdf.umd.min.js"></script>
|
||||||
|
<script src="vendor/purify.min.js"></script>
|
||||||
|
<script src="rich-note.js"></script>
|
||||||
|
<script src="mixed-note.js"></script>
|
||||||
|
<script type="module" src="views/note-shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -468,6 +468,15 @@ input[type="color"],
|
|||||||
padding: 6px 8px; word-break: break-word; flex-shrink: 0;
|
padding: 6px 8px; word-break: break-word; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ai-status.warn { color: var(--warn-text); border-color: var(--warn-line); }
|
.ai-status.warn { color: var(--warn-text); border-color: var(--warn-line); }
|
||||||
|
.ai-sessions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||||
|
.ai-session-label { font-size: 11px; color: var(--text-dim); flex: none; }
|
||||||
|
.ai-session-select {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--line); color: var(--text);
|
||||||
|
border-radius: 6px; padding: 3px 6px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.ai-session-actions { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
||||||
|
.ai-session-actions .tb-btn { flex: 1 1 auto; }
|
||||||
.ai-scope { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
.ai-scope { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||||
.ai-scope-label { font-size: 11px; color: var(--text-dim); flex: none; }
|
.ai-scope-label { font-size: 11px; color: var(--text-dim); flex: none; }
|
||||||
.ai-scope-select {
|
.ai-scope-select {
|
||||||
@@ -511,6 +520,31 @@ input[type="color"],
|
|||||||
.ai-output.streaming { border-color: var(--accent); }
|
.ai-output.streaming { border-color: var(--accent); }
|
||||||
.ai-output > :first-child { margin-top: 0; }
|
.ai-output > :first-child { margin-top: 0; }
|
||||||
.ai-output > :last-child { margin-bottom: 0; }
|
.ai-output > :last-child { margin-bottom: 0; }
|
||||||
|
.ai-thread { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.ai-thread-notice {
|
||||||
|
padding: 5px 8px; border: 1px dashed var(--line); border-radius: 7px;
|
||||||
|
color: var(--text-dim); font-size: 11px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.ai-msg { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.ai-msg-meta {
|
||||||
|
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
|
||||||
|
color: var(--text-dim); font-size: 11px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.ai-msg-context { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ai-msg-body {
|
||||||
|
padding: 7px 10px; border: 1px solid var(--line); border-radius: 9px;
|
||||||
|
background: var(--input-bg); overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ai-msg-body > :first-child { margin-top: 0; }
|
||||||
|
.ai-msg-body > :last-child { margin-bottom: 0; }
|
||||||
|
.ai-msg-body.ai-output-plain { white-space: pre-wrap; }
|
||||||
|
.ai-msg-user .ai-msg-body {
|
||||||
|
border-left: 2px solid var(--accent); background: var(--accent-faint); white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.ai-msg-assistant.ai-msg-streaming .ai-msg-body { border-color: var(--accent); }
|
||||||
|
.ai-msg-error .ai-msg-body { border-color: var(--danger); color: var(--error-text); }
|
||||||
|
.ai-msg-flags { color: var(--text-dim); font-size: 11px; line-height: 1.6; }
|
||||||
|
.ai-msg-actions { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
.ai-output p,
|
.ai-output p,
|
||||||
.ai-output ul,
|
.ai-output ul,
|
||||||
.ai-output ol,
|
.ai-output ol,
|
||||||
@@ -703,6 +737,25 @@ input[type="color"],
|
|||||||
.ai-confirm-notice {
|
.ai-confirm-notice {
|
||||||
color: var(--text-dim); font-size: 12px; line-height: 1.7;
|
color: var(--text-dim); font-size: 12px; line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
.ai-session-save-box { width: 500px; }
|
||||||
|
.ai-session-save-options {
|
||||||
|
display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.ai-session-save-option {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
min-height: 30px; padding: 7px 9px;
|
||||||
|
border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
background: var(--input-bg); color: var(--text); font-size: 13px;
|
||||||
|
}
|
||||||
|
.ai-session-save-option:has(input[type="radio"]:checked) { border-color: var(--accent); }
|
||||||
|
.ai-session-save-rounds {
|
||||||
|
width: 64px; min-width: 0; padding: 4px 6px;
|
||||||
|
border: 1px solid var(--line); border-radius: 6px;
|
||||||
|
background: var(--bg-card); color: var(--text); font: inherit;
|
||||||
|
}
|
||||||
|
.ai-session-save-rounds:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.ai-session-save-summary { margin-top: 4px; }
|
||||||
|
.ai-session-save-box .ai-confirm-notice { min-height: 1.7em; margin-bottom: 0; }
|
||||||
.pick-list { max-height: 48vh; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
.pick-list { max-height: 48vh; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
||||||
.pick-item {
|
.pick-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
|||||||