diff --git a/.gitignore b/.gitignore
index a194bb6..f8ef636 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ probe*.json
probe-*.js
*.tmp.html
scihub.html
+*-diagnostic.png
diff --git a/.npmrc b/.npmrc
index 087435f..0697b06 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,6 +1,2 @@
registry=https://registry.npmmirror.com
-proxy=
-https-proxy=
noproxy=*
-ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
-electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
diff --git a/README.md b/README.md
index 69e1ae4..0da6478 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,31 @@
开放获取文献与图书的桌面客户端(Electron)。在一个界面里检索多个公开文献源,查看详情,下载文件并归入本地书库。
+## 界面预览
+
+
+
## 功能
- **多源检索**:12 个数据源统一的搜索、详情、下载流程
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
+- **内置阅读器**:PDF、EPUB 与无 DRM 的 MOBI/KF7/KF8 阅读,支持进度、书签、选文和笔记
- **全局代理**:一处配置,对所有数据源与封面请求生效
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
- **版本更新**:手动或启动时检查 GitHub Releases,发现新版本后前往下载
+## 支持格式
+
+| 格式 | 书库导入与管理 | 内置阅读 | 说明 |
+|---|---:|---:|---|
+| PDF | ✓ | ✓ | 支持页面批注、书签、选文和笔记 |
+| EPUB | ✓ | ✓ | 支持目录、重排、书签、选文和笔记 |
+| MOBI / AZW / AZW3 | ✓ | ✓ | 使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容 |
+| TXT / DJVU / FB2 / CBZ / CBR | ✓ | — | 可入库、整理并调用系统关联应用打开 |
+
+DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,可改用系统关联应用打开。
+
## 数据源
| 源 | ID | 说明 |
@@ -38,11 +54,11 @@
2. 双击目录中的 `PeopleLib.exe`。
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。
-当前版本为 **1.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
+当前版本为 **1.3.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
## 源码运行与打包
-源码开发需要 Node.js 18+:
+源码开发需要 Node.js 22.19+:
```bash
npm install
@@ -55,7 +71,7 @@ npm start
npm run portable
```
-发布时将完整的 `dist/PeopleLib-1.1.0/` 目录压缩,上传到 GitHub Release,并使用 `v1.1.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
+发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v1.3.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
## 配置
diff --git a/build-portable.js b/build-portable.js
index 0698fa3..45f321b 100644
--- a/build-portable.js
+++ b/build-portable.js
@@ -1,15 +1,86 @@
const fs = require('fs');
const path = require('path');
+const { spawnSync } = require('child_process');
const ROOT = __dirname;
const pkg = require('./package.json');
-const OUT = process.env.PEOPLELIB_OUT_DIR
+const PRODUCT = pkg.productName || 'PeopleLib';
+const TARGET = `${PRODUCT}-windows-x64`;
+const REQUESTED_OUT = process.env.PEOPLELIB_OUT_DIR
? path.resolve(process.env.PEOPLELIB_OUT_DIR)
- : path.join(ROOT, 'dist', `PeopleLib-${pkg.version}`);
+ : path.join(ROOT, 'dist', TARGET);
+const OUT = REQUESTED_OUT;
const APP = path.join(OUT, 'resources', 'app');
-const PRODUCT = 'PeopleLib';
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
+function clearOutput(dir) {
+ if (!fs.existsSync(dir)) return;
+ const executable = path.join(dir, `${PRODUCT}.exe`);
+ const probe = executable + '.build-lock-check';
+ if (fs.existsSync(executable)) {
+ try {
+ fs.renameSync(executable, probe);
+ fs.renameSync(probe, executable);
+ } catch (error) {
+ if (!fs.existsSync(executable) && fs.existsSync(probe)) {
+ try { fs.renameSync(probe, executable); } catch (restoreError) { /* report the original lock error */ }
+ }
+ if (error && (error.code === 'EPERM' || error.code === 'EBUSY' || error.code === 'EACCES')) {
+ throw new Error(`无法清理固定输出目录 ${dir},请先关闭其中正在运行的 ${PRODUCT}.exe 后重试`);
+ }
+ throw error;
+ }
+ }
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === 'data') continue;
+ const target = path.join(dir, entry.name);
+ try {
+ fs.rmSync(target, { recursive: true, force: true });
+ } catch (error) {
+ if (error && (error.code === 'EPERM' || error.code === 'EBUSY')) {
+ throw new Error(`无法清理固定输出目录 ${dir},请先关闭其中正在运行的 ${PRODUCT}.exe 后重试`);
+ }
+ throw error;
+ }
+ }
+}
+
+function ensureElectronRuntime() {
+ const electronDir = path.join(ROOT, 'node_modules', 'electron');
+ const distDir = path.join(electronDir, 'dist');
+ const executable = path.join(distDir, 'electron.exe');
+ const versionFile = path.join(distDir, 'version');
+ const expected = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
+ const installed = fs.existsSync(versionFile)
+ ? fs.readFileSync(versionFile, 'utf8').trim().replace(/^v/, '')
+ : '';
+ if (installed === expected && fs.existsSync(executable)) return distDir;
+
+ const installer = path.join(electronDir, 'install.js');
+ if (!fs.existsSync(installer)) throw new Error('缺少 Electron 安装脚本,请先运行 npm ci');
+ console.log(`准备 Electron ${expected} 运行时...`);
+ const result = spawnSync(process.execPath, [installer], {
+ cwd: electronDir,
+ env: {
+ ...process.env,
+ ELECTRON_MIRROR: process.env.ELECTRON_MIRROR
+ || process.env.npm_config_electron_mirror
+ || 'https://npmmirror.com/mirrors/electron/'
+ },
+ stdio: 'inherit'
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) throw new Error(`Electron 运行时安装失败(退出码 ${result.status})`);
+
+ const prepared = fs.existsSync(versionFile)
+ ? fs.readFileSync(versionFile, 'utf8').trim().replace(/^v/, '')
+ : '';
+ if (prepared !== expected || !fs.existsSync(executable)) {
+ throw new Error(`Electron 运行时版本无效(期望 ${expected || '未知'},实际 ${prepared || '缺失'})`);
+ }
+ return distDir;
+}
+
function copyDir(src, dst, skip) {
fs.mkdirSync(dst, { recursive: true });
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
@@ -47,32 +118,86 @@ function copyUndici(dst) {
copyDir(path.join(src, 'lib'), path.join(dst, 'lib'), skipDevFiles);
}
-console.log('清理输出目录...');
-rimraf(OUT);
+function copyFoliate(dst) {
+ const src = path.join(ROOT, 'node_modules', 'foliate-js');
+ fs.mkdirSync(path.join(dst, 'vendor'), { recursive: true });
+ for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
+ fs.copyFileSync(path.join(src, name), path.join(dst, name));
+ }
+ fs.copyFileSync(
+ path.join(src, 'vendor', 'fflate.js'),
+ path.join(dst, 'vendor', 'fflate.js')
+ );
+}
-console.log('复制 Electron 运行时...');
-copyDir(path.join(ROOT, 'node_modules', 'electron', 'dist'), OUT);
+async function build() {
+ const electronDist = ensureElectronRuntime();
-console.log('精简语言包...');
-pruneLocales(path.join(OUT, 'locales'));
+ console.log('清理固定输出目录(保留 data)...');
+ clearOutput(OUT);
-console.log('重命名可执行文件...');
-fs.renameSync(path.join(OUT, 'electron.exe'), path.join(OUT, PRODUCT + '.exe'));
-rimraf(path.join(OUT, 'resources', 'default_app.asar'));
+ console.log('复制 Electron 运行时...');
+ copyDir(electronDist, OUT);
-console.log('组装 app 源码...');
-fs.mkdirSync(APP, { recursive: true });
-fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
-fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
-copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
-copyUndici(path.join(APP, 'node_modules', 'undici'));
+ console.log('精简语言包...');
+ pruneLocales(path.join(OUT, 'locales'));
-fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
- name: pkg.name, version: pkg.version, description: pkg.description,
- main: 'main.js', author: pkg.author, license: pkg.license,
- dependencies: { undici: pkg.dependencies.undici }
-}, null, 2));
+ console.log('重命名可执行文件...');
+ const executable = path.join(OUT, PRODUCT + '.exe');
+ fs.renameSync(path.join(OUT, 'electron.exe'), executable);
+ rimraf(path.join(OUT, 'resources', 'default_app.asar'));
-console.log('\n构建完成:');
-console.log(' 目录:', OUT);
-console.log(' 可执行文件:', path.join(OUT, PRODUCT + '.exe'));
+ console.log(`应用 ${PRODUCT} 图标...`);
+ const { rcedit } = await import('rcedit');
+ await rcedit(executable, {
+ icon: path.join(ROOT, 'icons', 'dist', 'book-ai-dark.ico'),
+ 'file-version': pkg.version,
+ 'product-version': pkg.version,
+ 'version-string': {
+ ProductName: PRODUCT,
+ FileDescription: PRODUCT,
+ InternalName: PRODUCT,
+ OriginalFilename: `${PRODUCT}.exe`
+ }
+ });
+
+ console.log('组装 app 源码...');
+ fs.mkdirSync(APP, { recursive: true });
+ fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
+ fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
+ copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
+ const iconDir = path.join(APP, 'icons', 'dist');
+ fs.mkdirSync(iconDir, { recursive: true });
+ for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
+ fs.copyFileSync(path.join(ROOT, 'icons', 'dist', name), path.join(iconDir, name));
+ }
+ for (const theme of ['dark', 'light']) {
+ const themeDir = path.join(iconDir, theme);
+ fs.mkdirSync(themeDir, { recursive: true });
+ fs.copyFileSync(
+ path.join(ROOT, 'icons', 'dist', theme, 'icon-32.png'),
+ path.join(themeDir, 'icon-32.png')
+ );
+ }
+ copyUndici(path.join(APP, 'node_modules', 'undici'));
+ copyFoliate(path.join(APP, 'node_modules', 'foliate-js'));
+
+ fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
+ name: pkg.name, version: pkg.version, description: pkg.description,
+ productName: PRODUCT,
+ main: 'main.js', author: pkg.author, license: pkg.license,
+ dependencies: {
+ undici: pkg.dependencies.undici,
+ 'foliate-js': pkg.dependencies['foliate-js']
+ }
+ }, null, 2));
+
+ console.log('\n构建完成:');
+ console.log(' 目录:', OUT);
+ console.log(' 可执行文件:', executable);
+}
+
+build().catch((error) => {
+ console.error('构建失败:', error && error.message ? error.message : error);
+ process.exitCode = 1;
+});
diff --git a/docs/screenshots/PeopleLib_2N6zVpCFBA.png b/docs/screenshots/PeopleLib_2N6zVpCFBA.png
new file mode 100644
index 0000000..901cd56
Binary files /dev/null and b/docs/screenshots/PeopleLib_2N6zVpCFBA.png differ
diff --git a/docs/screenshots/PeopleLib_ScQMUA2D24.png b/docs/screenshots/PeopleLib_ScQMUA2D24.png
new file mode 100644
index 0000000..893dd4e
Binary files /dev/null and b/docs/screenshots/PeopleLib_ScQMUA2D24.png differ
diff --git a/docs/screenshots/PeopleLib_bAecy2Izab.png b/docs/screenshots/PeopleLib_bAecy2Izab.png
new file mode 100644
index 0000000..ea07ef6
Binary files /dev/null and b/docs/screenshots/PeopleLib_bAecy2Izab.png differ
diff --git a/docs/screenshots/ai-send-confirmation.png b/docs/screenshots/ai-send-confirmation.png
new file mode 100644
index 0000000..05b14fe
Binary files /dev/null and b/docs/screenshots/ai-send-confirmation.png differ
diff --git a/docs/screenshots/pdf-annotations.png b/docs/screenshots/pdf-annotations.png
new file mode 100644
index 0000000..840700a
Binary files /dev/null and b/docs/screenshots/pdf-annotations.png differ
diff --git a/icons/ChatGPT_0CjIH8EmAj.png b/icons/ChatGPT_0CjIH8EmAj.png
new file mode 100644
index 0000000..9c5a13e
Binary files /dev/null and b/icons/ChatGPT_0CjIH8EmAj.png differ
diff --git a/icons/ChatGPT_RAga3pG7De.png b/icons/ChatGPT_RAga3pG7De.png
new file mode 100644
index 0000000..a715cea
Binary files /dev/null and b/icons/ChatGPT_RAga3pG7De.png differ
diff --git a/icons/tools/make_icons.py b/icons/tools/make_icons.py
new file mode 100644
index 0000000..399d0b8
--- /dev/null
+++ b/icons/tools/make_icons.py
@@ -0,0 +1,95 @@
+"""Crop the source renders to the icon tile and export PNG sizes plus .ico files."""
+import numpy as np
+from PIL import Image
+
+ROOT = "D:/my_git/peoplelib/icons"
+SIZES = [1024, 512, 256, 128, 64, 48, 32, 16]
+ICO_SIZES = [256, 128, 64, 48, 32, 16]
+SS = 4
+
+SOURCES = [
+ ("light", f"{ROOT}/ChatGPT_0CjIH8EmAj.png"),
+ ("dark", f"{ROOT}/ChatGPT_RAga3pG7De.png"),
+]
+
+
+def tile_mask(rgb):
+ a = rgb.astype(int)
+ r, g, b = a[..., 0], a[..., 1], a[..., 2]
+ # Anything that is not the near-white page background belongs to the artwork.
+ return (a.sum(2) < 748) | (np.abs(r - b) > 4) | (np.abs(g - b) > 4)
+
+
+def tile_bbox(mask):
+ cols = mask.sum(0)
+ rows = mask.sum(1)
+ xs = np.where(cols > cols.max() * 0.35)[0]
+ ys = np.where(rows > rows.max() * 0.35)[0]
+ x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
+ # The renders carry a drop shadow below the tile, so trust the width and
+ # square the crop downward from the top edge.
+ side = x1 - x0 + 1
+ return x0, x1, y0, y0 + side - 1
+
+
+def corner_radius(mask, x0, x1, y0, y1):
+ ests = []
+ for d in range(10, int((y1 - y0) * 0.18)):
+ idx = np.where(mask[y0 + d, x0:x1 + 1])[0]
+ if not len(idx) or idx.min() <= 0:
+ continue
+ x = float(idx.min())
+ ests.append((d + x) + np.sqrt(2.0 * d * x))
+ return float(np.median(ests)) if ests else (x1 - x0) * 0.21
+
+
+def rounded_alpha(w, h, radius, size):
+ n = size * SS
+ ys, xs = np.mgrid[0:n, 0:n].astype(np.float64)
+ # Map supersampled pixel centres back onto the source tile grid.
+ px = (xs + 0.5) / n * w
+ py = (ys + 0.5) / n * h
+ r = radius
+ dx = np.clip(r - px, 0, None) + np.clip(px - (w - r), 0, None)
+ dy = np.clip(r - py, 0, None) + np.clip(py - (h - r), 0, None)
+ inside = (dx * dx + dy * dy) <= r * r
+ cov = inside.reshape(size, SS, size, SS).mean((1, 3))
+ return (cov * 255).round().astype(np.uint8)
+
+
+def build(name, path):
+ src = Image.open(path).convert("RGB")
+ mask = tile_mask(np.asarray(src))
+ x0, x1, y0, y1 = tile_bbox(mask)
+ radius = corner_radius(mask, x0, x1, y0, y1)
+ tile = src.crop((x0, y0, x1 + 1, y1 + 1))
+ w, h = tile.size
+ print(f"{name}: tile {w}x{h} radius {radius:.1f}")
+
+ outdir = f"{ROOT}/dist/{name}"
+ import os
+ os.makedirs(outdir, exist_ok=True)
+
+ frames = {}
+ for size in SIZES:
+ img = tile.resize((size, size), Image.LANCZOS).convert("RGBA")
+ img.putalpha(Image.fromarray(rounded_alpha(w, h, radius, size), "L"))
+ img.save(f"{outdir}/icon-{size}.png", optimize=True)
+ frames[size] = img
+
+ frames[1024].save(f"{ROOT}/dist/book-ai-{name}.ico", format="ICO",
+ sizes=[(s, s) for s in ICO_SIZES])
+ return frames[512]
+
+
+def main():
+ previews = [build(n, p) for n, p in SOURCES]
+ gap = 32
+ sheet = Image.new("RGBA", (512 * 2 + gap * 3, 512 + gap * 2), (128, 128, 128, 255))
+ for i, img in enumerate(previews):
+ sheet.paste(img, (gap + i * (512 + gap), gap), img)
+ sheet.save(f"{ROOT}/dist/preview.png")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/main.js b/main.js
index fccc7e1..5be306e 100644
--- a/main.js
+++ b/main.js
@@ -1,6 +1,8 @@
-const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session, safeStorage } = require('electron');
+const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session, safeStorage, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
+const crypto = require('crypto');
+const { pathToFileURL } = require('url');
const { Readable, Transform } = require('stream');
const { pipeline } = require('stream/promises');
@@ -9,19 +11,28 @@ const RELEASES_API = 'https://api.github.com/repos/lofyer/peoplelib/releases/lat
const RELEASES_PAGE = 'https://github.com/lofyer/peoplelib/releases';
function parseVersion(v) {
- return String(v || '').replace(/^v/i, '').trim().split('.').map((n) => parseInt(n, 10) || 0);
+ const s = String(v || '').replace(/^v/i, '').trim();
+ const [core, pre = ''] = s.split(/[-+]/);
+ return {
+ nums: core.split('.').map((n) => parseInt(n, 10) || 0),
+ // 有预发布标记的版本低于同号正式版:1.1.0-beta < 1.1.0
+ pre: pre.toLowerCase()
+ };
}
function compareVersion(a, b) {
const pa = parseVersion(a);
const pb = parseVersion(b);
- const len = Math.max(pa.length, pb.length);
+ const len = Math.max(pa.nums.length, pb.nums.length);
for (let i = 0; i < len; i++) {
- const x = pa[i] || 0;
- const y = pb[i] || 0;
+ const x = pa.nums[i] || 0;
+ const y = pb.nums[i] || 0;
if (x !== y) return x > y ? 1 : -1;
}
- return 0;
+ if (pa.pre === pb.pre) return 0;
+ if (!pa.pre) return 1;
+ if (!pb.pre) return -1;
+ return pa.pre > pb.pre ? 1 : -1;
}
async function checkUpdate() {
@@ -65,6 +76,12 @@ function filenameFromResponse(res, fallback) {
}
app.setName('PeopleLib');
+app.setAppUserModelId('com.peoplelib.client');
+
+const APP_ICON_DIR = path.join(__dirname, 'icons', 'dist');
+function iconForTheme(theme) {
+ return path.join(APP_ICON_DIR, theme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico');
+}
const userDataDir = app.isPackaged
? path.join(path.dirname(app.getPath('exe')), 'data')
@@ -73,13 +90,31 @@ app.setPath('userData', userDataDir);
const sources = require('./src/sources');
const library = require('./src/library/store');
+const localImport = require('./src/library/local-import');
+const coverGenerator = require('./src/library/cover-generator');
const zlibAuth = require('./src/sources/zlib-auth');
const semanticKey = require('./src/sources/semantic-key');
const settings = require('./src/settings');
+const readerStore = require('./src/reader/store');
+const annotations = require('./src/reader/annotations');
+const noteAssets = require('./src/reader/note-assets');
+const readerWindow = require('./src/reader/window');
+const rangeSessions = require('./src/reader/range-sessions');
+const aiConfig = require('./src/reader/ai-config');
+const aiClient = require('./src/reader/ai-client');
+const { normalizeVisualContexts } = require('./src/reader/visual-context');
const { setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
-zlibAuth.init(userDataDir);
+zlibAuth.init(userDataDir, safeStorage);
semanticKey.init(userDataDir, safeStorage);
settings.init(userDataDir);
+let currentUiTheme = settings.get(
+ 'ui.theme',
+ settings.get('reader.uiTheme', 'dark')
+) === 'light' ? 'light' : 'dark';
+readerStore.init(userDataDir);
+annotations.init(userDataDir);
+noteAssets.init(userDataDir);
+aiConfig.init(userDataDir, safeStorage);
// 启动时从持久化设置恢复代理
try {
setProxy(settings.get('proxy', ''));
@@ -96,18 +131,37 @@ try {
console.warn('自定义书库目录不可用,已回退到默认目录:', e.message);
library.init(DEFAULT_LIBRARY_DIR);
}
-// 首次运行时把旧版本散落在 userData 根目录的书库数据导入一次
-if (!settings.get('legacyImported', false)) {
- try {
- library.importLegacy(userDataDir);
- settings.set('legacyImported', true);
- } catch (e) {
- console.warn('旧书库导入失败:', e.message);
- }
-}
+coverGenerator.init(__dirname, library);
+const legacyImportPending = !settings.get('legacyImported', false);
let mainWindow;
let activeDownloads = 0;
+let readerPurgeSeq = 0;
+let startupMaintenanceStarted = false;
+const readerPurgeWaiters = new Map();
+const purgedReaderEntries = new Set();
+const pendingLocalImports = new Map();
+
+function requestReaderPurge(entryId) {
+ const requestId = `purge_${Date.now().toString(36)}_${(++readerPurgeSeq).toString(36)}`;
+ if (!readerWindow.get()) return Promise.resolve();
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ readerPurgeWaiters.delete(requestId);
+ resolve();
+ }, 5000);
+ readerPurgeWaiters.set(requestId, () => {
+ clearTimeout(timer);
+ readerPurgeWaiters.delete(requestId);
+ resolve();
+ });
+ readerWindow.purgeFor(entryId, requestId);
+ });
+}
+
+function ensureReaderWritable(entryId) {
+ if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除');
+}
function createWindow() {
mainWindow = new BrowserWindow({
@@ -117,7 +171,8 @@ function createWindow() {
minHeight: 620,
frame: false,
backgroundColor: '#141414',
- title: 'PeopleLib 文献库',
+ icon: iconForTheme(currentUiTheme),
+ title: 'PeopleLib',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -125,6 +180,24 @@ function createWindow() {
}
});
mainWindow.loadFile(path.join(__dirname, 'src', 'ui', 'index.html'));
+ mainWindow.webContents.once('did-finish-load', () => {
+ setTimeout(runStartupMaintenance, 1500);
+ });
+}
+
+function runStartupMaintenance() {
+ if (startupMaintenanceStarted) return;
+ startupMaintenanceStarted = true;
+ try {
+ if (legacyImportPending) {
+ library.importLegacy(userDataDir);
+ settings.set('legacyImported', true);
+ }
+ library.scan();
+ for (const job of coverGenerator.ensureAll()) job.catch(() => {});
+ } catch (e) {
+ console.warn('启动维护任务失败:', e.message);
+ }
}
function notifyLibraryChanged() {
@@ -133,6 +206,33 @@ function notifyLibraryChanged() {
}
}
+function notifyNotesChanged(data) {
+ const payload = data && typeof data === 'object' ? data : {};
+ const windows = [mainWindow, ...readerWindow.all()];
+ for (const win of windows) {
+ if (win && !win.isDestroyed()) win.webContents.send('reader:notesChanged', payload);
+ }
+}
+
+function applyWindowIcons(theme) {
+ currentUiTheme = theme === 'light' ? 'light' : 'dark';
+ const icon = iconForTheme(currentUiTheme);
+ const windows = [mainWindow, ...readerWindow.all()];
+ for (const win of windows) {
+ if (!win || win.isDestroyed()) continue;
+ try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
+ }
+}
+
+function notifyUiThemeChanged() {
+ const windows = [mainWindow, ...readerWindow.all()];
+ for (const win of windows) {
+ if (win && !win.isDestroyed()) {
+ win.webContents.send('ui:themeChanged', currentUiTheme);
+ }
+ }
+}
+
library.setChangeListener(notifyLibraryChanged);
app.whenReady().then(() => {
@@ -142,8 +242,7 @@ app.whenReady().then(() => {
session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
}
createWindow();
- // 启动时扫描书库目录,用户手动放进 files/ 的书会自动入库
- try { library.scan(); } catch (e) { /* ignore */ }
+ setTimeout(cleanupNoteAssets, 0);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
@@ -152,22 +251,29 @@ app.whenReady().then(() => {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+app.on('before-quit', () => {
+ coverGenerator.close();
+ rangeSessions.closeAll().catch(() => {});
+});
-function wrap(promise) {
- return promise
+// fn 同步抛出时也必须变成 { ok:false },否则 invoke 直接 reject,
+// 渲染层的 await 没有 catch,界面会永远停在"加载中"。
+function wrap(fn) {
+ return Promise.resolve()
+ .then(typeof fn === 'function' ? fn : () => fn)
.then((data) => ({ ok: true, data }))
- .catch((err) => ({ ok: false, error: err.message || String(err) }));
+ .catch((err) => ({ ok: false, error: (err && err.message) || String(err) }));
}
// 数据源
-ipcMain.handle('sources:list', () => ({ ok: true, data: sources.listSources() }));
-ipcMain.handle('source:list', (_e, sourceId, page) => wrap(sources.getSource(sourceId).list(page)));
-ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(sources.getSource(sourceId).search(keyword, page)));
-ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(sources.getSource(sourceId).detail(postId)));
-ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(sources.getSource(sourceId).download(postId)));
+ipcMain.handle('sources:list', () => wrap(() => sources.listSources()));
+ipcMain.handle('source:list', (_e, sourceId, page) => wrap(() => sources.getSource(sourceId).list(page)));
+ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(() => sources.getSource(sourceId).search(keyword, page)));
+ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).detail(postId)));
+ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).download(postId)));
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
-ipcMain.handle('proxy:get', () => ({ ok: true, data: getProxy() }));
+ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
ipcMain.handle('proxy:set', (_e, url) => {
const previous = getProxy();
try {
@@ -182,20 +288,122 @@ ipcMain.handle('proxy:set', (_e, url) => {
}
});
+function zlibOrigin(value) {
+ let url;
+ try { url = new URL(String(value || '')); } catch (e) { throw new Error('Z-Library 镜像地址无效'); }
+ if (url.protocol !== 'https:' || url.username || url.password) {
+ throw new Error('Z-Library 镜像必须使用 HTTPS');
+ }
+ return url.origin;
+}
+
+async function waitForZlibPage(win, origin) {
+ const deadline = Date.now() + 30000;
+ while (Date.now() < deadline) {
+ if (win.isDestroyed()) throw new Error('Z-Library 登录页面已关闭');
+ const current = win.webContents.getURL();
+ const title = win.getTitle();
+ if (current.startsWith(`${origin}/`) && title && !/checking your browser/i.test(title)) return;
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ }
+ throw new Error('Z-Library 浏览器验证超时');
+}
+
+async function browserZlibLogin(mirror, email, password) {
+ const origin = zlibOrigin(mirror);
+ const win = new BrowserWindow({
+ show: false,
+ width: 900,
+ height: 700,
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ webSecurity: true,
+ backgroundThrottling: false
+ }
+ });
+ win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
+ win.webContents.on('will-attach-webview', (event) => event.preventDefault());
+ win.webContents.on('will-navigate', (event, target) => {
+ try {
+ if (new URL(target).origin !== origin) event.preventDefault();
+ } catch (e) {
+ event.preventDefault();
+ }
+ });
+
+ try {
+ win.loadURL(`${origin}/`).catch(() => {});
+ await waitForZlibPage(win, origin);
+ const body = new URLSearchParams({
+ isModal: 'true',
+ email: String(email),
+ password: String(password),
+ site_mode: 'books',
+ action: 'login',
+ isSingleLogin: '1',
+ redirectUrl: '',
+ gg_json_mode: '1'
+ }).toString();
+ const code = `fetch(${JSON.stringify(`${origin}/rpc.php`)}, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-Requested-With': 'XMLHttpRequest'
+ },
+ body: ${JSON.stringify(body)}
+ }).then(async (response) => ({
+ status: response.status,
+ contentType: response.headers.get('content-type') || '',
+ text: await response.text()
+ }))`;
+ const result = await win.webContents.executeJavaScriptInIsolatedWorld(
+ 1001,
+ [{ code }],
+ true
+ );
+ let data = null;
+ try { data = JSON.parse(result && result.text); } catch (e) { /* 非 JSON */ }
+ if (!data) {
+ if (/checking your browser|diamwall|cloudflare/i.test(String(result && result.text || ''))) {
+ throw new Error('登录镜像触发了浏览器验证');
+ }
+ throw new Error(`登录镜像未返回 JSON(HTTP ${Number(result && result.status) || 0})`);
+ }
+ const response = data.response && typeof data.response === 'object' ? data.response : {};
+ if (response.validationError || response.error) {
+ return { error: String(response.message || response.error || '登录失败') };
+ }
+ const cookies = await session.defaultSession.cookies.get({ url: `${origin}/` });
+ const userId = cookies.find((cookie) => cookie.name === 'remix_userid');
+ const userKey = cookies.find((cookie) => cookie.name === 'remix_userkey');
+ if (!userId || !userKey || !userId.value || !userKey.value) {
+ throw new Error('登录响应缺少会话信息');
+ }
+ return { userId: userId.value, userKey: userKey.value };
+ } finally {
+ if (!win.isDestroyed()) win.destroy();
+ }
+}
+
+sources.getSource('zlib').setLoginTransport(browserZlibLogin);
+
// Z-Library 凭据
-ipcMain.handle('zlib:hasCreds', () => ({ ok: true, data: zlibAuth.hasCreds() }));
-ipcMain.handle('zlib:login', (_e, email, password) => wrap(sources.getSource('zlib').login(email, password)));
-ipcMain.handle('zlib:logout', () => wrap(sources.getSource('zlib').logout()));
+ipcMain.handle('zlib:hasCreds', () => wrap(() => zlibAuth.hasCreds()));
+ipcMain.handle('zlib:login', (_e, email, password) => wrap(() => sources.getSource('zlib').login(email, password)));
+ipcMain.handle('zlib:logout', () => wrap(() => sources.getSource('zlib').logout()));
// Semantic Scholar API Key
-ipcMain.handle('semanticScholar:keyStatus', () => ({ ok: true, data: semanticKey.status() }));
-ipcMain.handle('semanticScholar:setKey', (_e, key) => wrap(Promise.resolve().then(() => semanticKey.write(key))));
-ipcMain.handle('semanticScholar:clearKey', () => wrap(Promise.resolve().then(() => semanticKey.clear())));
+ipcMain.handle('semanticScholar:keyStatus', () => wrap(() => semanticKey.status()));
+ipcMain.handle('semanticScholar:setKey', (_e, key) => wrap(() => semanticKey.write(key)));
+ipcMain.handle('semanticScholar:clearKey', () => wrap(() => semanticKey.clear()));
// 书库目录管理
-ipcMain.handle('library:getDir', () => ({ ok: true, data: { dir: library.getRoot(), isDefault: library.getRoot() === DEFAULT_LIBRARY_DIR } }));
+ipcMain.handle('library:getDir', () => wrap(() => ({ dir: library.getRoot(), isDefault: library.getRoot() === DEFAULT_LIBRARY_DIR })));
ipcMain.handle('library:pickDir', async () => {
- const r = await dialog.showOpenDialog(mainWindow, {
+ const r = await dialog.showOpenDialog(liveWindow(), {
title: '选择书库目录',
defaultPath: library.getRoot(),
properties: ['openDirectory', 'createDirectory']
@@ -204,7 +412,7 @@ ipcMain.handle('library:pickDir', async () => {
return { ok: true, data: r.filePaths[0] };
});
// migrate=true 时把现有数据搬到新目录,否则只切换(旧目录原样保留)
-ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(Promise.resolve().then(() => {
+ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => {
const dest = String(dir || '').trim();
if (!dest) throw new Error('目录不能为空');
if (activeDownloads) throw new Error('请等待当前下载完成后再切换书库目录');
@@ -220,6 +428,7 @@ ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(Promise.resolve().th
settings.set('libraryDir', dest);
if (migrate) library.finalizeMigration();
notifyLibraryChanged();
+ for (const job of coverGenerator.ensureAll()) job.catch(() => {});
return { dir: library.getRoot(), ...r };
} catch (e) {
if (migrate) {
@@ -230,29 +439,85 @@ ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(Promise.resolve().th
try { settings.set('libraryDir', previousSetting); } catch (rollbackError) { /* ignore */ }
throw e;
}
-})));
-ipcMain.handle('library:scan', () => wrap(Promise.resolve().then(() => {
+}));
+ipcMain.handle('library:scan', () => wrap(() => {
const r = library.scan();
notifyLibraryChanged();
+ for (const job of coverGenerator.ensureAll()) job.catch(() => {});
return r;
-})));
+}));
// 本地书库
-ipcMain.handle('library:list', () => wrap(Promise.resolve(library.list())));
-ipcMain.handle('library:get', (_e, id) => wrap(Promise.resolve(library.get(id))));
-ipcMain.handle('library:findBySource', (_e, sourceId, postId) => wrap(Promise.resolve(library.findBySource(sourceId, postId))));
-ipcMain.handle('library:add', (_e, item) => wrap(Promise.resolve(library.add(item))));
-ipcMain.handle('library:update', (_e, id, patch) => wrap(Promise.resolve(library.update(id, patch))));
-ipcMain.handle('library:remove', (_e, id, deleteFiles) => wrap(Promise.resolve(library.remove(id, deleteFiles))));
+ipcMain.handle('library:list', () => wrap(() => library.list().map((item) => ({
+ ...item,
+ lastReadAt: readerStore.getLastReadAt(item.id)
+}))));
+ipcMain.handle('library:get', (_e, id) => wrap(() => library.get(id)));
+ipcMain.handle('library:listShelves', () => wrap(() => library.listShelves()));
+ipcMain.handle('library:listTags', () => wrap(() => library.listTags()));
+ipcMain.handle('library:addShelf', (_e, input) => wrap(() => library.addShelf(input)));
+ipcMain.handle('library:updateShelf', (_e, shelfId, patch) => wrap(() => library.updateShelf(shelfId, patch)));
+ipcMain.handle('library:removeShelf', (_e, shelfId) => wrap(() => library.removeShelf(shelfId)));
+ipcMain.handle('library:addTag', (_e, input) => wrap(() => library.addTag(input)));
+ipcMain.handle('library:updateTag', (_e, tagId, patch) => wrap(() => library.updateTag(tagId, patch)));
+ipcMain.handle('library:removeTag', (_e, tagId) => wrap(() => library.removeTag(tagId)));
+ipcMain.handle('library:findBySource', (_e, sourceId, postId) => wrap(() => library.findBySource(sourceId, postId)));
+ipcMain.handle('library:add', (_e, item) => wrap(() => {
+ const entry = library.add(item);
+ coverGenerator.ensure(entry.id).catch(() => {});
+ return entry;
+}));
+ipcMain.handle('library:update', (_e, id, patch) => wrap(() => {
+ const entry = library.update(id, patch);
+ coverGenerator.ensure(entry.id).catch(() => {});
+ return entry;
+}));
+ipcMain.handle('library:remove', (_e, id, options) => wrap(() => {
+ const deleteFiles = options && typeof options === 'object'
+ ? options.deleteFiles === true
+ : options === true;
+ const deleteReadingData = !!(
+ options && typeof options === 'object' && options.deleteReadingData === true
+ );
+ return (async () => {
+ await requestReaderPurge(id);
+ if (deleteReadingData) {
+ const key = String(id);
+ purgedReaderEntries.add(key);
+ try {
+ readerStore.forget(id);
+ annotations.forget(id);
+ cleanupNoteAssets();
+ } catch (e) {
+ purgedReaderEntries.delete(key);
+ throw e;
+ }
+ notifyNotesChanged({ entryId: String(id), type: 'forget' });
+ }
+ try {
+ const removed = library.remove(id, deleteFiles);
+ if (!removed && deleteReadingData) purgedReaderEntries.delete(String(id));
+ return removed;
+ } catch (e) {
+ if (deleteReadingData) purgedReaderEntries.delete(String(id));
+ throw e;
+ }
+ })();
+}));
// 下载文件:默认直接存入书库目录并挂到条目上;
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
-ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeaders, meta) => {
+ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => {
activeDownloads++;
let partial = '';
let res = null;
let bodyHandled = false;
+ const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
+ const sendProgress = (data) => {
+ if (!progressId || event.sender.isDestroyed()) return;
+ event.sender.send('download:progress', { requestId: progressId, ...data });
+ };
try {
const parsedUrl = new URL(String(url || ''));
if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接');
@@ -260,7 +525,7 @@ ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeade
const suggested = library.sanitize(suggestName || 'download.bin');
let target;
if (askSavePath) {
- const save = await dialog.showSaveDialog(mainWindow, {
+ const save = await dialog.showSaveDialog(liveWindow(), {
title: '保存文件',
defaultPath: path.join(library.filesDir(), suggested)
});
@@ -282,6 +547,11 @@ ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeade
clearTimeout(timer);
}
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 hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName);
@@ -309,6 +579,16 @@ ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeade
const activity = new Transform({
transform(chunk, encoding, callback) {
refreshTransferTimer();
+ receivedBytes += chunk.length;
+ const now = Date.now();
+ if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) {
+ lastProgressAt = now;
+ sendProgress({
+ receivedBytes,
+ totalBytes,
+ percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null
+ });
+ }
callback(null, chunk);
}
});
@@ -351,6 +631,7 @@ ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeade
try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
partial = '';
}
+ sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true });
// 落库:优先挂到已有条目,否则用 meta 新建
let id = entryId;
@@ -360,6 +641,7 @@ ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeade
id = existing ? existing.id : library.add(meta).id;
}
const entry = id ? library.attachFile(id, target) : null;
+ if (entry) coverGenerator.ensure(entry.id).catch(() => {});
return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } };
} catch (e) {
@@ -400,28 +682,549 @@ ipcMain.handle('shell:openExternal', async (_e, url) => {
catch (e) { return { ok: false, error: e.message || String(e) }; }
});
-ipcMain.handle('dialog:pickFile', async () => {
- const r = await dialog.showOpenDialog(mainWindow, {
- title: '选择本地文献文件',
- properties: ['openFile'],
- filters: [{ name: '文献', extensions: ['pdf', 'epub', 'mobi', 'txt', 'azw3'] }, { name: '所有文件', extensions: ['*'] }]
+ipcMain.handle('dialog:pickLocal', (event, kind) => wrap(async () => {
+ const sourceKind = kind === 'folder' ? 'folder' : 'files';
+ const r = await dialog.showOpenDialog(liveWindow(), {
+ title: sourceKind === 'folder' ? '选择本地图书文件夹' : '选择本地图书文件',
+ properties: sourceKind === 'folder'
+ ? ['openDirectory']
+ : ['openFile', 'multiSelections'],
+ filters: sourceKind === 'folder'
+ ? undefined
+ : [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr'] }]
});
- if (r.canceled || !r.filePaths.length) return { ok: true, data: null };
- const p = r.filePaths[0];
- return { ok: true, data: { path: p, name: path.basename(p, path.extname(p)) } };
+ if (r.canceled || !r.filePaths.length) return null;
+ const records = await localImport.discover(r.filePaths);
+ if (!records.length) {
+ throw new Error('所选位置中没有支持的图书文件');
+ }
+ const now = Date.now();
+ for (const [id, pending] of pendingLocalImports) {
+ if (now - pending.createdAt > 10 * 60 * 1000) pendingLocalImports.delete(id);
+ }
+ const selectionId = crypto.randomUUID();
+ pendingLocalImports.set(selectionId, {
+ senderId: event.sender.id,
+ records,
+ kind: sourceKind,
+ createdAt: now
+ });
+ return {
+ selectionId,
+ kind: sourceKind,
+ paths: r.filePaths.map((file) => path.resolve(file)),
+ count: records.length,
+ sample: records.slice(0, 5)
+ };
+}));
+
+ipcMain.handle('library:importLocal', (event, selectionId, options) => wrap(async () => {
+ const id = String(selectionId || '');
+ const pending = pendingLocalImports.get(id);
+ if (!pending) {
+ throw new Error('本地导入选择已失效,请重新选择');
+ }
+ if (pending.senderId !== event.sender.id) throw new Error('无权使用该本地导入选择');
+ pendingLocalImports.delete(id);
+ if (Date.now() - pending.createdAt > 10 * 60 * 1000) {
+ throw new Error('本地导入选择已过期,请重新选择');
+ }
+ const organization = options && ['none', 'shelf', 'tag'].includes(options.organization)
+ ? options.organization
+ : 'none';
+ const records = pending.records.map((record) => ({ ...record }));
+ if (records.length === 1 && options && typeof options === 'object') {
+ records[0].title = String(options.title || '').trim();
+ const author = String(options.author || '').trim();
+ records[0].authors = author ? [author] : [];
+ }
+ const result = library.importLocal(records, organization);
+ for (const item of result.items) coverGenerator.ensure(item.id).catch(() => {});
+ return { ...result, discovered: records.length };
+}));
+
+// --- 阅读器 ---
+
+const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3']);
+function isReaderSender(webContents) {
+ const expected = pathToFileURL(path.join(__dirname, 'src', 'ui', 'reader.html')).href;
+ return !!readerWindow.fromWebContents(webContents)
+ || String(webContents.getURL() || '').startsWith(expected);
+}
+
+ipcMain.handle('reader:ready', (event) => wrap(() => readerWindow.markReady(event.sender)));
+ipcMain.handle('reader:captureRect', (event, rect) => wrap(async () => {
+ if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以截取文档内容');
+ const win = BrowserWindow.fromWebContents(event.sender);
+ if (!win || win.isDestroyed()) throw new Error('阅读器窗口不可用');
+ const value = rect && typeof rect === 'object' ? rect : {};
+ const area = {
+ x: Math.floor(Number(value.x)),
+ y: Math.floor(Number(value.y)),
+ width: Math.floor(Number(value.width)),
+ height: Math.floor(Number(value.height))
+ };
+ const [contentWidth, contentHeight] = win.getContentSize();
+ if (
+ !Object.values(area).every(Number.isFinite)
+ || area.x < 0 || area.y < 0
+ || area.width < 2 || area.height < 2
+ || area.width > 4096 || area.height > 4096
+ || area.x + area.width > contentWidth
+ || area.y + area.height > contentHeight
+ ) {
+ throw new Error('截图区域无效或超出阅读器窗口');
+ }
+ let image = await event.sender.capturePage(area);
+ if (image.isEmpty()) throw new Error('没有截取到文档图像');
+ let size = image.getSize();
+ const longest = Math.max(size.width, size.height);
+ if (longest > 2048) {
+ const ratio = 2048 / longest;
+ image = image.resize({
+ width: Math.max(1, Math.round(size.width * ratio)),
+ height: Math.max(1, Math.round(size.height * ratio)),
+ quality: 'best'
+ });
+ size = image.getSize();
+ }
+ let data = null;
+ for (const quality of [90, 82, 72, 62]) {
+ const candidate = image.toJPEG(quality);
+ if (candidate.length <= 3 * 1024 * 1024) {
+ data = candidate;
+ break;
+ }
+ }
+ if (!data) throw new Error('截图数据超过 3 MB');
+ return {
+ mimeType: 'image/jpeg',
+ base64: data.toString('base64'),
+ width: size.width,
+ height: size.height,
+ bytes: data.length
+ };
+}));
+ipcMain.on('reader:purgeReady', (event, requestId) => {
+ if (!readerWindow.fromWebContents(event.sender)) return;
+ const resolve = readerPurgeWaiters.get(String(requestId));
+ if (resolve) resolve();
+});
+ipcMain.on('reader:shutdownReady', (event) => {
+ readerWindow.shutdownReady(event.sender);
+});
+
+// 只允许读取书库中真实登记过的文件,杜绝渲染层传任意路径读盘
+function resolveReadable(entryId, fileIndex, documentKey) {
+ const item = library.get(entryId);
+ if (!item) throw new Error('条目不存在');
+ const files = (item.files || []).filter((f) => f && f.path);
+ if (!files.length) throw new Error('该条目还没有可阅读的文件');
+ let idx = Number.isInteger(fileIndex) ? fileIndex : files.findIndex((f) => READABLE_EXT.has(path.extname(f.path).toLowerCase()));
+ const expectedKey = /^[a-f0-9]{64}$/.test(String(documentKey || '')) ? String(documentKey) : '';
+ if (expectedKey) {
+ const matched = files.findIndex((candidate) => {
+ if (!candidate || !candidate.path || !READABLE_EXT.has(path.extname(candidate.path).toLowerCase())) return false;
+ if (!fs.existsSync(candidate.path)) return false;
+ try { return annotations.documentKey(candidate.path) === expectedKey; } catch (e) { return false; }
+ });
+ if (matched < 0) throw new Error('笔记关联的原始文件已变更或不存在');
+ idx = matched;
+ }
+ const resolvedIndex = idx >= 0 ? idx : 0;
+ const file = files[resolvedIndex];
+ if (!file) throw new Error('找不到指定文件');
+ const abs = path.resolve(file.path);
+ const ext = path.extname(abs).toLowerCase();
+ if (!READABLE_EXT.has(ext)) throw new Error(`暂不支持在阅读器中打开 ${ext || '该格式'} 文件`);
+ if (!fs.existsSync(abs)) throw new Error('文件不存在,可能已被移动或删除');
+ return { item, file, abs, format: ext.slice(1), fileIndex: resolvedIndex };
+}
+
+rangeSessions.init(resolveReadable);
+const rangeSessionSenders = new Set();
+function trackRangeSessionSender(webContents) {
+ const senderId = webContents.id;
+ if (rangeSessionSenders.has(senderId)) return;
+ rangeSessionSenders.add(senderId);
+ webContents.once('destroyed', () => {
+ rangeSessionSenders.delete(senderId);
+ rangeSessions.closeSender(senderId).catch(() => {});
+ });
+}
+
+ipcMain.handle('reader:open', (_e, entryId, fileIndex) => wrap(() => {
+ const { item, abs, format, fileIndex: resolvedIndex } = resolveReadable(entryId, fileIndex);
+ readerWindow.open(entryId, __dirname, resolvedIndex, null, currentUiTheme);
+ return { entryId, title: item.title, format, path: abs, fileIndex: resolvedIndex };
+}));
+
+ipcMain.handle('reader:openAt', (_e, entryId, fileIndex, documentKey, locator) => wrap(() => {
+ const resolved = resolveReadable(entryId, fileIndex, documentKey);
+ const target = locator && typeof locator === 'object' ? locator : null;
+ readerWindow.open(entryId, __dirname, resolved.fileIndex, target, currentUiTheme);
+ return {
+ entryId,
+ title: resolved.item.title,
+ format: resolved.format,
+ path: resolved.abs,
+ fileIndex: resolved.fileIndex,
+ locator: target
+ };
+}));
+
+ipcMain.handle('reader:meta', (_e, entryId, fileIndex) => wrap(() => {
+ const { item, abs, format, fileIndex: resolvedIndex } = resolveReadable(entryId, fileIndex);
+ const fileSize = fs.statSync(abs).size;
+ const documentKey = annotations.documentKey(abs);
+ readerStore.setBookSnapshot(String(entryId), {
+ title: item.title || '',
+ authors: item.authors || []
+ });
+ readerStore.bindDocument(String(entryId), documentKey);
+ const files = (item.files || []).filter((f) => f && f.path).map((f, i) => ({
+ index: i,
+ name: f.name || path.basename(f.path),
+ format: path.extname(f.path).toLowerCase().slice(1),
+ readable: READABLE_EXT.has(path.extname(f.path).toLowerCase())
+ }));
+ return {
+ entryId,
+ title: item.title,
+ authors: item.authors || [],
+ format,
+ documentKey,
+ fileIndex: resolvedIndex,
+ size: fileSize,
+ files,
+ state: readerStore.getState(entryId, documentKey)
+ };
+}));
+
+const MAX_BUFFERED_READER_BYTES = 256 * 1024 * 1024;
+
+function readBoundedFile(abs, maxBytes) {
+ const fd = fs.openSync(abs, 'r');
+ try {
+ const stat = fs.fstatSync(fd);
+ if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size > maxBytes) {
+ throw new Error('该电子书超过 256 MB,暂不支持在内置阅读器中打开,请使用外部应用');
+ }
+ const buffer = Buffer.allocUnsafe(stat.size);
+ let offset = 0;
+ while (offset < buffer.length) {
+ const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
+ if (!bytesRead) break;
+ offset += bytesRead;
+ }
+ const after = fs.fstatSync(fd);
+ if (offset !== buffer.length || after.size !== stat.size
+ || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
+ throw new Error('电子书文件在读取期间发生变化,请重试');
+ }
+ return buffer;
+ } finally {
+ fs.closeSync(fd);
+ }
+}
+
+ipcMain.handle('reader:rangeOpen', (event, entryId, fileIndex) => wrap(async () => {
+ if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以创建 PDF 分段读取会话');
+ trackRangeSessionSender(event.sender);
+ return rangeSessions.open(event.sender.id, entryId, fileIndex);
+}));
+ipcMain.handle('reader:rangeRead', (event, sessionId, begin, end) => wrap(() => {
+ if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以读取 PDF 分段数据');
+ return rangeSessions.read(event.sender.id, sessionId, begin, end);
+}));
+ipcMain.handle('reader:rangeClose', (event, sessionId) => wrap(() => {
+ if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以关闭 PDF 分段读取会话');
+ return rangeSessions.close(event.sender.id, sessionId);
+}));
+
+ipcMain.handle('reader:bytes', (_e, entryId, fileIndex) => wrap(() => {
+ const { abs, format } = resolveReadable(entryId, fileIndex);
+ if (format === 'pdf') throw new Error('PDF 必须使用分段读取');
+ return readBoundedFile(abs, MAX_BUFFERED_READER_BYTES);
+}));
+ipcMain.handle('reader:openExternal', (_e, entryId, fileIndex) => wrap(async () => {
+ const { abs } = resolveReadable(entryId, fileIndex);
+ const error = await shell.openPath(abs);
+ if (error) throw new Error(error);
+ return true;
+}));
+
+ipcMain.handle('reader:getState', (_e, entryId, documentKey) => wrap(() => (
+ readerStore.getState(String(entryId), documentKey)
+)));
+ipcMain.handle('reader:setProgress', (_e, entryId, documentKey, locator, percent) => wrap(() => {
+ ensureReaderWritable(entryId);
+ return readerStore.setProgress(String(entryId), documentKey, locator, percent);
+}));
+ipcMain.handle('reader:addBookmark', (_e, entryId, mark) => wrap(() => {
+ ensureReaderWritable(entryId);
+ return readerStore.addBookmark(String(entryId), mark);
+}));
+ipcMain.handle('reader:removeBookmark', (_e, entryId, markId) => wrap(() => {
+ ensureReaderWritable(entryId);
+ return readerStore.removeBookmark(String(entryId), markId);
+}));
+function notePayloadWithAssets(event, value) {
+ const note = value && typeof value === 'object' ? { ...value } : value;
+ if (!note || typeof note !== 'object' || !Object.prototype.hasOwnProperty.call(note, 'canvasContent')) {
+ return { note, tokens: [] };
+ }
+ const resolved = noteAssets.resolveDrafts(note.canvasContent, event.sender.id);
+ note.canvasContent = resolved.content;
+ return { note, tokens: resolved.tokens };
+}
+
+function cleanupNoteAssets() {
+ try { noteAssets.cleanup(readerStore.noteAssetIds()); } catch (error) { /* 后续保存时重试 */ }
+}
+
+ipcMain.handle('reader:addNote', (event, entryId, note) => wrap(() => {
+ const id = String(entryId);
+ ensureReaderWritable(id);
+ const item = library.get(id);
+ if (item) readerStore.setBookSnapshot(id, { title: item.title || '', authors: item.authors || [] });
+ const prepared = notePayloadWithAssets(event, note);
+ const result = readerStore.addNote(id, prepared.note);
+ noteAssets.commitTokens(prepared.tokens);
+ cleanupNoteAssets();
+ notifyNotesChanged({ entryId: id, noteId: result.id, type: 'add' });
+ return result;
+}));
+ipcMain.handle('reader:addStandaloneNote', (event, note) => wrap(() => {
+ const prepared = notePayloadWithAssets(event, note);
+ const result = readerStore.addStandaloneNote(prepared.note);
+ noteAssets.commitTokens(prepared.tokens);
+ cleanupNoteAssets();
+ notifyNotesChanged({
+ entryId: readerStore.STANDALONE_ENTRY_ID,
+ noteId: result.id,
+ type: 'add'
+ });
+ return result;
+}));
+ipcMain.handle('reader:updateNote', (event, entryId, noteId, patch) => wrap(() => {
+ const id = String(entryId);
+ ensureReaderWritable(id);
+ const prepared = notePayloadWithAssets(event, patch);
+ const result = readerStore.updateNote(id, noteId, prepared.note);
+ if (result) {
+ noteAssets.commitTokens(prepared.tokens);
+ cleanupNoteAssets();
+ notifyNotesChanged({ entryId: id, noteId: result.id, type: 'update' });
+ }
+ return result;
+}));
+ipcMain.handle('reader:removeNote', (_e, entryId, noteId) => wrap(() => {
+ const id = String(entryId);
+ ensureReaderWritable(id);
+ const result = readerStore.removeNote(id, noteId);
+ if (result) {
+ cleanupNoteAssets();
+ notifyNotesChanged({ entryId: id, noteId: String(noteId), type: 'remove' });
+ }
+ return result;
+}));
+ipcMain.handle('reader:listNotes', (_e, filters) => wrap(() => readerStore.listNotes(filters || {})));
+ipcMain.handle('reader:getNoteCounts', () => wrap(() => readerStore.getNoteCounts()));
+ipcMain.handle('reader:listCollections', () => wrap(() => readerStore.listCollections()));
+ipcMain.handle('reader:addCollection', (_e, input) => wrap(() => {
+ const result = readerStore.addCollection(input);
+ notifyNotesChanged({ collectionId: result.id, type: 'collection-add' });
+ return result;
+}));
+ipcMain.handle('reader:updateCollection', (_e, collectionId, patch) => wrap(() => {
+ const result = readerStore.updateCollection(collectionId, patch);
+ if (result) notifyNotesChanged({ collectionId: result.id, type: 'collection-update' });
+ return result;
+}));
+ipcMain.handle('reader:removeCollection', (_e, collectionId) => wrap(() => {
+ const result = readerStore.removeCollection(collectionId);
+ if (result) notifyNotesChanged({ collectionId: String(collectionId), type: 'collection-remove' });
+ return result;
+}));
+ipcMain.handle('reader:pickNotePdf', (event) => wrap(async () => {
+ const result = await dialog.showOpenDialog(senderWindow(event), {
+ title: '选择 PDF 笔记底版',
+ properties: ['openFile'],
+ filters: [{ name: 'PDF 文档', extensions: ['pdf'] }]
+ });
+ if (result.canceled || !result.filePaths.length) return null;
+ return noteAssets.stagePdf(result.filePaths[0], event.sender.id);
+}));
+ipcMain.handle('reader:notePdfBytes', (event, ref) => wrap(() => {
+ const value = ref && typeof ref === 'object' ? ref : {};
+ if (value.draftToken) return noteAssets.readDraft(value.draftToken, event.sender.id);
+ const assetId = noteAssets.safeAssetId(value.assetId);
+ if (!readerStore.noteAssetIds().includes(assetId)) throw new Error('PDF 笔记底版不存在');
+ return noteAssets.readAsset(assetId);
+}));
+ipcMain.handle('reader:saveNotePdf', (event, bytes, suggestedName) => wrap(async () => {
+ const data = Buffer.isBuffer(bytes)
+ ? Buffer.from(bytes)
+ : ArrayBuffer.isView(bytes)
+ ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ : bytes instanceof ArrayBuffer
+ ? Buffer.from(bytes)
+ : null;
+ if (!data || !data.length || data.length > 100 * 1024 * 1024
+ || data.subarray(0, 5).toString('ascii') !== '%PDF-') {
+ throw new Error('导出的 PDF 数据无效或超过 100 MB');
+ }
+ const base = String(suggestedName || 'PeopleLib-笔记.pdf')
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
+ .slice(0, 180);
+ const result = await dialog.showSaveDialog(senderWindow(event), {
+ title: '导出画布笔记',
+ defaultPath: base.toLowerCase().endsWith('.pdf') ? base : `${base}.pdf`,
+ filters: [{ name: 'PDF 文档', extensions: ['pdf'] }]
+ });
+ if (result.canceled || !result.filePath) return { canceled: true };
+ fs.writeFileSync(result.filePath, data);
+ return { canceled: false };
+}));
+ipcMain.handle('reader:getAnnotations', (_e, entryId, fileIndex) => wrap(() => {
+ const resolved = resolveReadable(entryId, fileIndex);
+ if (resolved.format !== 'pdf') throw new Error('只有 PDF 支持页面批注');
+ return annotations.get(String(entryId), annotations.documentKey(resolved.abs));
+}));
+ipcMain.handle('reader:setAnnotationPage', (_e, entryId, fileIndex, page, data) => wrap(() => {
+ ensureReaderWritable(entryId);
+ const resolved = resolveReadable(entryId, fileIndex);
+ if (resolved.format !== 'pdf') throw new Error('只有 PDF 支持页面批注');
+ return annotations.setPage(String(entryId), annotations.documentKey(resolved.abs), page, data);
+}));
+
+// --- AI ---
+
+function notifyAiChanged(status) {
+ for (const win of BrowserWindow.getAllWindows()) {
+ if (!win.isDestroyed()) win.webContents.send('ai:changed', status);
+ }
+}
+
+ipcMain.handle('ai:status', () => wrap(() => aiConfig.status()));
+ipcMain.handle('ai:save', (_e, cfg) => wrap(() => {
+ const status = aiConfig.save(cfg || {});
+ notifyAiChanged(status);
+ return status;
+}));
+ipcMain.handle('ai:clear', () => wrap(() => {
+ const status = aiConfig.clear();
+ notifyAiChanged(status);
+ return status;
+}));
+
+const aiRuns = new Map();
+
+function aiRunKey(senderId, runId) {
+ return `${senderId}:${runId}`;
+}
+
+function canonicalVisualContexts(raw) {
+ return normalizeVisualContexts(raw).map((context) => {
+ if (!context.includeImage || !context.image) return context;
+ const source = Buffer.from(context.image.base64, 'base64');
+ const decoded = nativeImage.createFromBuffer(source);
+ if (decoded.isEmpty()) throw new Error('无法解码上下文图像');
+ const size = decoded.getSize();
+ if (size.width !== context.image.width || size.height !== context.image.height) {
+ throw new Error('图像解码尺寸不匹配');
+ }
+ const data = decoded.toJPEG(85);
+ if (!data.length || data.length > 3 * 1024 * 1024) throw new Error('图像编码后超过 3 MB');
+ return {
+ ...context,
+ image: {
+ mimeType: 'image/jpeg',
+ base64: data.toString('base64'),
+ width: size.width,
+ height: size.height,
+ bytes: data.length
+ }
+ };
+ });
+}
+
+ipcMain.handle('ai:cancel', (event, runId) => wrap(() => {
+ if (!isReaderSender(event.sender)) return false;
+ const run = aiRuns.get(aiRunKey(event.sender.id, String(runId)));
+ if (!run) return false;
+ run.controller.abort();
+ return true;
+}));
+
+// 流式:增量通过 ai:delta 事件推给发起窗口,最终结果由 invoke 返回
+ipcMain.handle('ai:run', async (e, payload) => {
+ const { runId, task, text, question, visualContexts } = payload || {};
+ const id = String(runId || '');
+ if (!isReaderSender(e.sender)) return { ok: false, error: '只有阅读器可以使用 AI 助手' };
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: 'runId 无效' };
+ const key = aiRunKey(e.sender.id, id);
+ if (aiRuns.has(key)) return { ok: false, error: '该请求已在进行中' };
+
+ const ctl = new AbortController();
+ const wc = e.sender;
+ const abortOnDestroy = () => ctl.abort();
+ wc.once('destroyed', abortOnDestroy);
+ aiRuns.set(key, { controller: ctl, senderId: wc.id });
+ try {
+ const visuals = canonicalVisualContexts(visualContexts);
+ const full = await aiClient.stream({
+ task,
+ text,
+ question,
+ visualContexts: visuals,
+ signal: ctl.signal,
+ onDelta: (piece) => {
+ if (!wc.isDestroyed()) wc.send('ai:delta', { runId: id, delta: piece });
+ }
+ });
+ return { ok: true, data: { text: full } };
+ } catch (err) {
+ if (err && err.name === 'AbortError') return { ok: false, error: '已取消', cancelled: true };
+ return { ok: false, error: (err && err.message) || String(err) };
+ } finally {
+ wc.removeListener('destroyed', abortOnDestroy);
+ aiRuns.delete(key);
+ }
});
// 通用设置读写(目前用于"下载前询问保存位置"开关)
-ipcMain.handle('settings:get', (_e, key, def) => ({ ok: true, data: settings.get(key, def) }));
-ipcMain.handle('settings:set', (_e, key, value) => { settings.set(key, value); return { ok: true }; });
+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('ui:getTheme', () => wrap(() => currentUiTheme));
+ipcMain.handle('ui:setTheme', (_e, value) => wrap(() => {
+ const theme = value === 'light' ? 'light' : 'dark';
+ settings.set('ui.theme', theme);
+ settings.set('reader.uiTheme', theme);
+ applyWindowIcons(theme);
+ notifyUiThemeChanged();
+ return theme;
+}));
-ipcMain.handle('app:version', () => ({ ok: true, data: app.getVersion() }));
-ipcMain.handle('app:checkUpdate', () => wrap(checkUpdate()));
+ipcMain.handle('app:version', () => wrap(() => app.getVersion()));
+ipcMain.handle('app:checkUpdate', () => wrap(checkUpdate));
ipcMain.handle('copy', (_e, text) => { clipboard.writeText(String(text || '')); return { ok: true }; });
-ipcMain.on('win:minimize', () => mainWindow && mainWindow.minimize());
-ipcMain.on('win:maximize', () => {
- if (!mainWindow) return;
- if (mainWindow.isMaximized()) mainWindow.unmaximize(); else mainWindow.maximize();
+function liveWindow() {
+ return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
+}
+
+// 窗口按钮要作用于发出请求的那个窗口,否则阅读器窗口的最小化/关闭会误操作主窗口
+function senderWindow(e) {
+ const w = BrowserWindow.fromWebContents(e.sender);
+ return w && !w.isDestroyed() ? w : liveWindow();
+}
+
+ipcMain.on('win:minimize', (e) => { const w = senderWindow(e); if (w) w.minimize(); });
+ipcMain.on('win:maximize', (e) => {
+ const w = senderWindow(e);
+ if (!w) return;
+ if (w.isMaximized()) w.unmaximize(); else w.maximize();
});
-ipcMain.on('win:close', () => mainWindow && mainWindow.close());
+ipcMain.on('win:close', (e) => { const w = senderWindow(e); if (w) w.close(); });
diff --git a/package-lock.json b/package-lock.json
index 2a81a83..7f5d3b9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,180 +9,810 @@
"version": "1.1.0",
"license": "MIT",
"dependencies": {
- "undici": "^6.21.3"
+ "foliate-js": "1.0.1",
+ "undici": "8.9.0"
},
"devDependencies": {
- "electron": "^31.0.0"
- }
- },
- "node_modules/@electron/get": {
- "version": "2.0.3",
- "resolved": "https://registry.npmmirror.com/@electron/get/-/get-2.0.3.tgz",
- "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.1.1",
- "env-paths": "^2.2.0",
- "fs-extra": "^8.1.0",
- "got": "^11.8.5",
- "progress": "^2.0.3",
- "semver": "^6.2.0",
- "sumchecker": "^3.0.1"
+ "dompurify": "3.4.12",
+ "electron": "43.2.0",
+ "fabric": "7.4.0",
+ "jspdf": "4.2.1",
+ "jszip": "3.10.1",
+ "markdown-it": "15.0.0",
+ "pdfjs-dist": "6.2.108",
+ "quill": "2.0.2",
+ "rcedit": "5.0.2"
},
"engines": {
- "node": ">=12"
- },
- "optionalDependencies": {
- "global-agent": "^3.0.0"
+ "node": ">=22.19.0"
}
},
- "node_modules/@sindresorhus/is": {
- "version": "4.6.0",
- "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz",
- "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/is?sponsor=1"
- }
- },
- "node_modules/@szmarczak/http-timer": {
- "version": "4.0.6",
- "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
- "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "defer-to-connect": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@types/cacheable-request": {
- "version": "6.0.3",
- "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
- "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/http-cache-semantics": "*",
- "@types/keyv": "^3.1.4",
- "@types/node": "*",
- "@types/responselike": "^1.0.0"
- }
- },
- "node_modules/@types/http-cache-semantics": {
- "version": "4.2.0",
- "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
- "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/keyv": {
- "version": "3.1.4",
- "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz",
- "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@types/node": {
- "version": "20.19.43",
- "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz",
- "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
- "node_modules/@types/responselike": {
- "version": "1.0.3",
- "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz",
- "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@types/yauzl": {
- "version": "2.10.3",
- "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
- "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@types/node": "*"
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
}
},
- "node_modules/boolean": {
- "version": "3.2.0",
- "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz",
- "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
- "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@electron-internal/extract-zip": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
+ "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@electron/get": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmmirror.com/@electron/get/-/get-5.1.0.tgz",
+ "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^3.0.0",
+ "graceful-fs": "^4.2.11",
+ "progress": "^2.0.3",
+ "semver": "^7.6.3",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "optionalDependencies": {
+ "undici": "^7.24.4"
+ }
+ },
+ "node_modules/@electron/get/node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/@malept/cross-spawn-promise": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
+ "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/malept"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund"
+ }
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.3.tgz",
+ "integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "1.0.3",
+ "@napi-rs/canvas-darwin-arm64": "1.0.3",
+ "@napi-rs/canvas-darwin-x64": "1.0.3",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
+ "@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
+ "@napi-rs/canvas-linux-arm64-musl": "1.0.3",
+ "@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
+ "@napi-rs/canvas-linux-x64-gnu": "1.0.3",
+ "@napi-rs/canvas-linux-x64-musl": "1.0.3",
+ "@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
+ "@napi-rs/canvas-win32-x64-msvc": "1.0.3"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
+ "integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
+ "integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
+ "integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
+ "integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
+ "integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
+ "integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
+ "integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
+ "integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
+ "integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
+ "integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
+ "integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/pako": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"dev": true,
"license": "MIT",
"optional": true
},
- "node_modules/buffer-crc32": {
- "version": "0.2.13",
- "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
- "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT",
+ "optional": true
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
"engines": {
- "node": "*"
+ "node": ">= 14"
}
},
- "node_modules/cacheable-lookup": {
- "version": "5.0.4",
- "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
- "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "node_modules/argparse": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/argparse/-/argparse-3.0.0.tgz",
+ "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "Python-2.0"
+ },
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
- "node": ">=10.6.0"
+ "node": ">= 0.6.0"
}
},
- "node_modules/cacheable-request": {
- "version": "7.0.4",
- "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz",
- "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
- "clone-response": "^1.0.2",
- "get-stream": "^5.1.0",
- "http-cache-semantics": "^4.0.0",
- "keyv": "^4.0.0",
- "lowercase-keys": "^2.0.0",
- "normalize-url": "^6.0.1",
- "responselike": "^2.0.0"
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/bl/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
},
"engines": {
- "node": ">=8"
+ "node": ">= 6"
}
},
- "node_modules/clone-response": {
- "version": "1.0.3",
- "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz",
- "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/canvas": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/canvas/-/canvas-3.2.3.tgz",
+ "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-addon-api": "^7.0.0",
+ "prebuild-install": "^7.1.3"
+ },
+ "engines": {
+ "node": "^18.12.0 || >= 20.9.0"
+ }
+ },
+ "node_modules/canvg": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
- "mimic-response": "^1.0.0"
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
},
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/construct-style-sheets-polyfill": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/construct-style-sheets-polyfill/-/construct-style-sheets-polyfill-3.1.0.tgz",
+ "integrity": "sha512-HBLKP0chz8BAY6rBdzda11c3wAZeCZ+kIG4weVC2NM3AXzxx09nhe8t0SQNdloAvg5GLuHwq/0SPOOSPvtCcKw==",
+ "license": "MIT"
+ },
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cross-spawn-windows-exe": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/cross-spawn-windows-exe/-/cross-spawn-windows-exe-1.2.0.tgz",
+ "integrity": "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/malept"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/subscription/pkg/npm-cross-spawn-windows-exe?utm_medium=referral&utm_source=npm_fund"
+ }
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@malept/cross-spawn-promise": "^1.1.0",
+ "is-wsl": "^2.2.0",
+ "which": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/debug": {
@@ -203,12 +833,21 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
@@ -219,92 +858,55 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/decompress-response/node_modules/mimic-response": {
- "version": "3.1.0",
- "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/defer-to-connect": {
- "version": "2.0.1",
- "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
- "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/define-data-property": {
- "version": "1.1.4",
- "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
- "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"dev": true,
"license": "MIT",
"optional": true,
- "dependencies": {
- "es-define-property": "^1.0.0",
- "es-errors": "^1.3.0",
- "gopd": "^1.0.1"
- },
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">=4.0.0"
}
},
- "node_modules/define-properties": {
- "version": "1.2.1",
- "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz",
- "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
- "dependencies": {
- "define-data-property": "^1.0.1",
- "has-property-descriptors": "^1.0.0",
- "object-keys": "^1.1.1"
- },
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">=8"
}
},
- "node_modules/detect-node": {
- "version": "2.1.0",
- "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
- "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "node_modules/dompurify": {
+ "version": "3.4.12",
+ "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz",
+ "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"dev": true,
- "license": "MIT",
- "optional": true
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
},
"node_modules/electron": {
- "version": "31.7.7",
- "resolved": "https://registry.npmmirror.com/electron/-/electron-31.7.7.tgz",
- "integrity": "sha512-HZtZg8EHsDGnswFt0QeV8If8B+et63uD6RJ7I4/xhcXqmTIbI08GoubX/wm+HdY0DwcuPe1/xsgqpmYvjdjRoA==",
+ "version": "43.2.0",
+ "resolved": "https://registry.npmmirror.com/electron/-/electron-43.2.0.tgz",
+ "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==",
"dev": true,
- "hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@electron/get": "^2.0.0",
- "@types/node": "^20.9.0",
- "extract-zip": "^2.0.1"
+ "@electron-internal/extract-zip": "^1.0.1",
+ "@electron/get": "^5.0.0",
+ "@types/node": "^24.9.0"
},
"bin": {
- "electron": "cli.js"
+ "electron": "cli.js",
+ "install-electron": "install.js"
},
"engines": {
- "node": ">= 12.20.55"
+ "node": ">= 22.12.0"
}
},
"node_modules/end-of-stream": {
@@ -313,216 +915,137 @@
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"once": "^1.4.0"
}
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-paths": {
- "version": "2.2.1",
- "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
- "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz",
+ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"dev": true,
"license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "dev": true,
+ "license": "(MIT OR WTFPL)",
+ "optional": true,
"engines": {
"node": ">=6"
}
},
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "node_modules/fabric": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmmirror.com/fabric/-/fabric-7.4.0.tgz",
+ "integrity": "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA==",
"dev": true,
"license": "MIT",
- "optional": true,
"engines": {
- "node": ">= 0.4"
+ "node": ">=20.0.0"
+ },
+ "optionalDependencies": {
+ "canvas": "^3.2.0",
+ "jsdom": "^26.1.0"
}
},
- "node_modules/es-errors": {
+ "node_modules/fast-diff": {
"version": "1.3.0",
- "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/fast-png": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz",
+ "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"dev": true,
"license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 0.4"
+ "dependencies": {
+ "@types/pako": "^2.0.3",
+ "iobuffer": "^5.3.2",
+ "pako": "^2.1.0"
}
},
- "node_modules/es6-error": {
- "version": "4.1.1",
- "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz",
- "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "node_modules/fast-png/node_modules/pako": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
+ "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/foliate-js": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/foliate-js/-/foliate-js-1.0.1.tgz",
+ "integrity": "sha512-Cj4h2ub5aVA+yUgbhvVhCyxwi0GPF4pyNBa6Lw9+6WKY1ReBxipItn2kEBO6u7Vu/xYXjK711R74+t+yW/0u5w==",
+ "license": "MIT",
+ "dependencies": {
+ "construct-style-sheets-polyfill": "^3.1.0"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true,
"license": "MIT",
"optional": true
},
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/extract-zip": {
- "version": "2.0.1",
- "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz",
- "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "debug": "^4.1.1",
- "get-stream": "^5.1.0",
- "yauzl": "^2.10.0"
- },
- "bin": {
- "extract-zip": "cli.js"
- },
- "engines": {
- "node": ">= 10.17.0"
- },
- "optionalDependencies": {
- "@types/yauzl": "^2.9.1"
- }
- },
- "node_modules/fd-slicer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz",
- "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pend": "~1.2.0"
- }
- },
- "node_modules/fs-extra": {
- "version": "8.1.0",
- "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz",
- "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
- "node_modules/get-stream": {
- "version": "5.2.0",
- "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz",
- "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pump": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/global-agent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz",
- "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
- "dev": true,
- "license": "BSD-3-Clause",
- "optional": true,
- "dependencies": {
- "boolean": "^3.0.1",
- "es6-error": "^4.1.1",
- "matcher": "^3.0.0",
- "roarr": "^2.15.3",
- "semver": "^7.3.2",
- "serialize-error": "^7.0.1"
- },
- "engines": {
- "node": ">=10.0"
- }
- },
- "node_modules/global-agent/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "optional": true,
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/globalthis": {
- "version": "1.0.4",
- "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz",
- "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "define-properties": "^1.2.1",
- "gopd": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/got": {
- "version": "11.8.6",
- "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz",
- "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@sindresorhus/is": "^4.0.0",
- "@szmarczak/http-timer": "^4.0.5",
- "@types/cacheable-request": "^6.0.1",
- "@types/responselike": "^1.0.0",
- "cacheable-lookup": "^5.0.3",
- "cacheable-request": "^7.0.2",
- "decompress-response": "^6.0.0",
- "http2-wrapper": "^1.0.0-beta.5.2",
- "lowercase-keys": "^2.0.0",
- "p-cancelable": "^2.0.0",
- "responselike": "^2.0.0"
- },
- "engines": {
- "node": ">=10.19.0"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/got?sponsor=1"
- }
+ "optional": true
},
"node_modules/graceful-fs": {
"version": "4.2.11",
@@ -531,110 +1054,394 @@
"dev": true,
"license": "ISC"
},
- "node_modules/has-property-descriptors": {
- "version": "1.0.2",
- "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
- "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "es-define-property": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/http-cache-semantics": {
- "version": "4.2.0",
- "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
- "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
- "dev": true,
- "license": "BSD-2-Clause"
- },
- "node_modules/http2-wrapper": {
- "version": "1.0.3",
- "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
- "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "quick-lru": "^5.1.1",
- "resolve-alpn": "^1.0.0"
+ "whatwg-encoding": "^3.1.1"
},
"engines": {
- "node": ">=10.19.0"
+ "node": ">=18"
}
},
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true,
"license": "MIT"
},
- "node_modules/json-stringify-safe": {
- "version": "5.0.1",
- "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
- "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true,
"license": "ISC",
"optional": true
},
- "node_modules/jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "node_modules/iobuffer": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz",
+ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/lowercase-keys": {
- "version": "2.0.0",
- "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
- "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
- "dev": true,
- "license": "MIT",
+ "is-docker": "^2.0.0"
+ },
"engines": {
"node": ">=8"
}
},
- "node_modules/matcher": {
- "version": "3.0.0",
- "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz",
- "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "escape-string-regexp": "^4.0.0"
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
},
"engines": {
- "node": ">=10"
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
}
},
- "node_modules/mimic-response": {
- "version": "1.0.1",
- "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz",
- "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
+ "node_modules/jspdf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz",
+ "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">=4"
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "fast-png": "^6.2.0",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.11",
+ "core-js": "^3.6.0",
+ "dompurify": "^3.3.1",
+ "html2canvas": "^1.0.0-rc.5"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "dev": true,
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/linkify-it": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-6.1.0.tgz",
+ "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^3.0.0"
+ }
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/markdown-it": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-15.0.0.tgz",
+ "integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^3.0.0",
+ "entities": "^8.0.0",
+ "linkify-it": "^6.0.0",
+ "mdurl": "^2.1.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^3.0.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/markdown-it/node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.1.0.tgz",
+ "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -642,54 +1449,147 @@
"dev": true,
"license": "MIT"
},
- "node_modules/normalize-url": {
- "version": "6.1.0",
- "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz",
- "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
+ "optional": true
},
- "node_modules/object-keys": {
- "version": "1.1.1",
- "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz",
- "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "node_modules/node-abi": {
+ "version": "3.94.0",
+ "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.94.0.tgz",
+ "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"dev": true,
"license": "MIT",
"optional": true,
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
"engines": {
- "node": ">= 0.4"
+ "node": ">=10"
}
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.24",
+ "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.24.tgz",
+ "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
+ "optional": true,
"dependencies": {
"wrappy": "1"
}
},
- "node_modules/p-cancelable": {
- "version": "2.1.1",
- "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz",
- "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
- "node_modules/pend": {
- "version": "1.2.0",
- "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
- "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "node_modules/pdfjs-dist": {
+ "version": "6.2.108",
+ "resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
+ "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=22.13.0 || >=24"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^1.0.0"
+ }
+ },
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT"
},
@@ -709,106 +1609,302 @@
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
- "node_modules/quick-lru": {
- "version": "5.1.1",
- "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz",
- "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "node": ">=6"
}
},
- "node_modules/resolve-alpn": {
- "version": "1.2.1",
- "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
- "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "node_modules/quill": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/quill/-/quill-2.0.2.tgz",
+ "integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dev": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "optional": true,
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/rcedit": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-5.0.2.tgz",
+ "integrity": "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn-windows-exe": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 22.12.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "dev": true,
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
- "node_modules/responselike": {
- "version": "2.0.1",
- "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz",
- "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "lowercase-keys": "^2.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
+ "optional": true
},
- "node_modules/roarr": {
- "version": "2.15.4",
- "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz",
- "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
- "license": "BSD-3-Clause",
+ "license": "ISC",
"optional": true,
"dependencies": {
- "boolean": "^3.0.1",
- "detect-node": "^2.0.4",
- "globalthis": "^1.0.1",
- "json-stringify-safe": "^5.0.1",
- "semver-compare": "^1.0.0",
- "sprintf-js": "^1.1.2"
+ "xmlchars": "^2.2.0"
},
"engines": {
- "node": ">=8.0"
+ "node": ">=v12.22.7"
}
},
"node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
- }
- },
- "node_modules/semver-compare": {
- "version": "1.0.0",
- "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz",
- "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/serialize-error": {
- "version": "7.0.1",
- "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz",
- "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "type-fest": "^0.13.1"
},
"engines": {
"node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/sprintf-js": {
- "version": "1.1.3",
- "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz",
- "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true,
- "license": "BSD-3-Clause",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
"optional": true
},
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -822,44 +1918,269 @@
"node": ">= 8.0"
}
},
- "node_modules/type-fest": {
- "version": "0.13.1",
- "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz",
- "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"dev": true,
- "license": "(MIT OR CC0-1.0)",
+ "license": "MIT",
"optional": true,
"engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "node": ">=12.0.0"
}
},
- "node_modules/undici": {
- "version": "6.28.0",
- "resolved": "https://registry.npmmirror.com/undici/-/undici-6.28.0.tgz",
- "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
"license": "MIT",
- "engines": {
- "node": ">=18.17"
+ "optional": true
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
}
},
- "node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tar-stream/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-3.0.0.tgz",
+ "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
"dev": true,
"license": "MIT"
},
- "node_modules/universalify": {
- "version": "0.1.2",
- "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz",
- "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
- "dev": true,
+ "node_modules/undici": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-8.9.0.tgz",
+ "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"license": "MIT",
"engines": {
- "node": ">= 4.0.0"
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
}
},
"node_modules/wrappy": {
@@ -867,18 +2188,50 @@
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
- "license": "ISC"
+ "license": "ISC",
+ "optional": true
},
- "node_modules/yauzl": {
- "version": "2.10.0",
- "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz",
- "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "node_modules/ws": {
+ "version": "8.21.1",
+ "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "buffer-crc32": "~0.2.3",
- "fd-slicer": "~1.1.0"
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
}
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
}
}
}
diff --git a/package.json b/package.json
index f4d6202..b2b7384 100644
--- a/package.json
+++ b/package.json
@@ -1,19 +1,33 @@
{
"name": "peoplelib",
- "version": "1.1.0",
+ "version": "1.3.0",
"description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library)",
"main": "main.js",
"author": "peoplelib",
"license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ },
"scripts": {
"start": "electron .",
+ "test": "node --test \"src/_test/*.test.js\"",
+ "build": "node build-portable.js",
"portable": "node build-portable.js"
},
"dependencies": {
- "undici": "^6.21.3"
+ "foliate-js": "1.0.1",
+ "undici": "8.9.0"
},
"devDependencies": {
- "electron": "^31.0.0"
+ "dompurify": "3.4.12",
+ "electron": "43.2.0",
+ "fabric": "7.4.0",
+ "jspdf": "4.2.1",
+ "jszip": "3.10.1",
+ "markdown-it": "15.0.0",
+ "pdfjs-dist": "6.2.108",
+ "quill": "2.0.2",
+ "rcedit": "5.0.2"
},
"build": {
"appId": "com.peoplelib.client",
@@ -24,10 +38,14 @@
"files": [
"main.js",
"preload.js",
- "src/**/*"
+ "src/**/*",
+ "icons/dist/*.ico",
+ "icons/dist/dark/icon-32.png",
+ "icons/dist/light/icon-32.png"
],
"win": {
- "target": "portable"
+ "target": "portable",
+ "icon": "icons/dist/book-ai-dark.ico"
},
"portable": {
"artifactName": "PeopleLib-${version}.exe"
diff --git a/preload.js b/preload.js
index 7b50698..3f7f35e 100644
--- a/preload.js
+++ b/preload.js
@@ -1,5 +1,41 @@
const { contextBridge, ipcRenderer } = require('electron');
+let downloadSeq = 0;
+function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
+ const requestId = `dl_${Date.now().toString(36)}_${(++downloadSeq).toString(36)}`;
+ const listener = (_event, data) => {
+ if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
+ try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
+ };
+ if (typeof onProgress === 'function') ipcRenderer.on('download:progress', listener);
+ return ipcRenderer
+ .invoke('download:file', url, suggestName, entryId, extraHeaders, meta, requestId)
+ .finally(() => ipcRenderer.removeListener('download:progress', listener));
+}
+
+function captureReaderRect(rect) {
+ const value = rect && typeof rect === 'object' ? rect : {};
+ const area = {
+ x: Number(value.x),
+ y: Number(value.y),
+ width: Number(value.width),
+ height: Number(value.height)
+ };
+ const documentArea = document.getElementById('docArea');
+ const bounds = documentArea && documentArea.getBoundingClientRect();
+ if (
+ !bounds
+ || !Object.values(area).every(Number.isFinite)
+ || area.x < bounds.left - 1
+ || area.y < bounds.top - 1
+ || area.x + area.width > bounds.right + 1
+ || area.y + area.height > bounds.bottom + 1
+ ) {
+ return Promise.resolve({ ok: false, error: '只能截取阅读正文区域' });
+ }
+ return ipcRenderer.invoke('reader:captureRect', area);
+}
+
contextBridge.exposeInMainWorld('api', {
sources: {
list: () => ipcRenderer.invoke('sources:list'),
@@ -11,21 +47,46 @@ contextBridge.exposeInMainWorld('api', {
library: {
list: () => ipcRenderer.invoke('library:list'),
get: (id) => ipcRenderer.invoke('library:get', id),
+ listShelves: () => ipcRenderer.invoke('library:listShelves'),
+ listTags: () => ipcRenderer.invoke('library:listTags'),
+ addShelf: (input) => ipcRenderer.invoke('library:addShelf', input),
+ updateShelf: (id, patch) => ipcRenderer.invoke('library:updateShelf', id, patch),
+ removeShelf: (id) => ipcRenderer.invoke('library:removeShelf', id),
+ addTag: (input) => ipcRenderer.invoke('library:addTag', input),
+ updateTag: (id, patch) => ipcRenderer.invoke('library:updateTag', id, patch),
+ removeTag: (id) => ipcRenderer.invoke('library:removeTag', id),
findBySource: (sourceId, postId) => ipcRenderer.invoke('library:findBySource', sourceId, postId),
add: (item) => ipcRenderer.invoke('library:add', item),
update: (id, patch) => ipcRenderer.invoke('library:update', id, patch),
- remove: (id, deleteFiles) => ipcRenderer.invoke('library:remove', id, deleteFiles),
+ remove: (id, options) => ipcRenderer.invoke('library:remove', id, options),
getDir: () => ipcRenderer.invoke('library:getDir'),
pickDir: () => ipcRenderer.invoke('library:pickDir'),
setDir: (dir, migrate) => ipcRenderer.invoke('library:setDir', dir, migrate),
+ pickLocal: (kind) => ipcRenderer.invoke('dialog:pickLocal', kind),
+ importLocal: (selectionId, options) => (
+ ipcRenderer.invoke('library:importLocal', selectionId, options)
+ ),
scan: () => ipcRenderer.invoke('library:scan'),
- onChanged: (cb) => ipcRenderer.on('library:changed', () => cb())
+ onChanged: (cb) => {
+ const h = () => cb();
+ ipcRenderer.on('library:changed', h);
+ return () => ipcRenderer.removeListener('library:changed', h);
+ }
},
settings: {
get: (key, def) => ipcRenderer.invoke('settings:get', key, def),
set: (key, value) => ipcRenderer.invoke('settings:set', key, value)
},
- downloadFile: (url, suggestName, entryId, extraHeaders, meta) => ipcRenderer.invoke('download:file', url, suggestName, entryId, extraHeaders, meta),
+ ui: {
+ getTheme: () => ipcRenderer.invoke('ui:getTheme'),
+ setTheme: (theme) => ipcRenderer.invoke('ui:setTheme', theme),
+ onThemeChanged: (cb) => {
+ const h = (_event, theme) => cb(theme);
+ ipcRenderer.on('ui:themeChanged', h);
+ return () => ipcRenderer.removeListener('ui:themeChanged', h);
+ }
+ },
+ downloadFile,
zlib: {
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
@@ -40,7 +101,96 @@ contextBridge.exposeInMainWorld('api', {
get: () => ipcRenderer.invoke('proxy:get'),
set: (url) => ipcRenderer.invoke('proxy:set', url)
},
- pickFile: () => ipcRenderer.invoke('dialog:pickFile'),
+ reader: {
+ ready: () => ipcRenderer.invoke('reader:ready'),
+ open: (entryId, fileIndex) => ipcRenderer.invoke('reader:open', entryId, fileIndex),
+ openAt: (entryId, fileIndex, documentKey, locator) => (
+ ipcRenderer.invoke('reader:openAt', entryId, fileIndex, documentKey, locator)
+ ),
+ meta: (entryId, fileIndex) => ipcRenderer.invoke('reader:meta', entryId, fileIndex),
+ bytes: (entryId, fileIndex) => ipcRenderer.invoke('reader:bytes', entryId, fileIndex),
+ rangeOpen: (entryId, fileIndex) => ipcRenderer.invoke('reader:rangeOpen', entryId, fileIndex),
+ rangeRead: (sessionId, begin, end) => (
+ ipcRenderer.invoke('reader:rangeRead', sessionId, begin, end)
+ ),
+ rangeClose: (sessionId) => ipcRenderer.invoke('reader:rangeClose', sessionId),
+ captureRect: (rect) => captureReaderRect(rect),
+ openExternal: (entryId, fileIndex) => (
+ ipcRenderer.invoke('reader:openExternal', entryId, fileIndex)
+ ),
+ getState: (entryId, documentKey) => ipcRenderer.invoke('reader:getState', entryId, documentKey),
+ setProgress: (entryId, documentKey, locator, percent) => (
+ ipcRenderer.invoke('reader:setProgress', entryId, documentKey, locator, percent)
+ ),
+ addBookmark: (entryId, mark) => ipcRenderer.invoke('reader:addBookmark', entryId, mark),
+ removeBookmark: (entryId, markId) => ipcRenderer.invoke('reader:removeBookmark', entryId, markId),
+ addNote: (entryId, note) => ipcRenderer.invoke('reader:addNote', entryId, note),
+ addStandaloneNote: (note) => ipcRenderer.invoke('reader:addStandaloneNote', note),
+ updateNote: (entryId, noteId, patch) => ipcRenderer.invoke('reader:updateNote', entryId, noteId, patch),
+ removeNote: (entryId, noteId) => ipcRenderer.invoke('reader:removeNote', entryId, noteId),
+ listNotes: (filters) => ipcRenderer.invoke('reader:listNotes', filters),
+ getNoteCounts: () => ipcRenderer.invoke('reader:getNoteCounts'),
+ listCollections: () => ipcRenderer.invoke('reader:listCollections'),
+ addCollection: (input) => ipcRenderer.invoke('reader:addCollection', input),
+ updateCollection: (id, patch) => ipcRenderer.invoke('reader:updateCollection', id, patch),
+ removeCollection: (id) => ipcRenderer.invoke('reader:removeCollection', id),
+ pickNotePdf: () => ipcRenderer.invoke('reader:pickNotePdf'),
+ notePdfBytes: (ref) => ipcRenderer.invoke('reader:notePdfBytes', ref),
+ saveNotePdf: (bytes, suggestedName) => (
+ ipcRenderer.invoke('reader:saveNotePdf', bytes, suggestedName)
+ ),
+ getAnnotations: (entryId, fileIndex) => ipcRenderer.invoke('reader:getAnnotations', entryId, fileIndex),
+ setAnnotationPage: (entryId, fileIndex, page, data) => ipcRenderer.invoke('reader:setAnnotationPage', entryId, fileIndex, page, data),
+ onOpenEntry: (cb) => {
+ const h = (_e, data) => cb(data);
+ ipcRenderer.on('reader:openEntry', h);
+ return () => ipcRenderer.removeListener('reader:openEntry', h);
+ },
+ onCloseEntry: (cb) => {
+ const h = (_e, entryId) => cb(entryId);
+ ipcRenderer.on('reader:closeEntry', h);
+ return () => ipcRenderer.removeListener('reader:closeEntry', h);
+ },
+ onPurgeEntry: (cb) => {
+ const h = async (_e, data) => {
+ try { await cb(data); } finally {
+ if (data && data.requestId) ipcRenderer.send('reader:purgeReady', data.requestId);
+ }
+ };
+ ipcRenderer.on('reader:purgeEntry', h);
+ return () => ipcRenderer.removeListener('reader:purgeEntry', h);
+ },
+ onPrepareClose: (cb) => {
+ const h = async () => {
+ try { await cb(); } finally { ipcRenderer.send('reader:shutdownReady'); }
+ };
+ ipcRenderer.on('reader:prepareClose', h);
+ return () => ipcRenderer.removeListener('reader:prepareClose', h);
+ },
+ onNotesChanged: (cb) => {
+ const h = (_e, data) => cb(data);
+ ipcRenderer.on('reader:notesChanged', h);
+ return () => ipcRenderer.removeListener('reader:notesChanged', h);
+ }
+ },
+ ai: {
+ status: () => ipcRenderer.invoke('ai:status'),
+ save: (cfg) => ipcRenderer.invoke('ai:save', cfg),
+ clear: () => ipcRenderer.invoke('ai:clear'),
+ run: (payload) => ipcRenderer.invoke('ai:run', payload),
+ cancel: (runId) => ipcRenderer.invoke('ai:cancel', runId),
+ onChanged: (cb) => {
+ const h = (_e, data) => cb(data);
+ ipcRenderer.on('ai:changed', h);
+ return () => ipcRenderer.removeListener('ai:changed', h);
+ },
+ // 返回取消订阅函数:阅读器窗口关闭时要能解绑,否则监听器会越积越多
+ onDelta: (cb) => {
+ const h = (_e, data) => cb(data);
+ ipcRenderer.on('ai:delta', h);
+ return () => ipcRenderer.removeListener('ai:delta', h);
+ }
+ },
openPath: (p) => ipcRenderer.invoke('shell:openPath', p),
showItem: (p) => ipcRenderer.invoke('shell:showItem', p),
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
diff --git a/src/_test/ai.test.js b/src/_test/ai.test.js
new file mode 100644
index 0000000..ac7a3df
--- /dev/null
+++ b/src/_test/ai.test.js
@@ -0,0 +1,361 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const h = require('./helpers');
+
+h.installFetchStub();
+
+const cfgPath = require.resolve('../reader/ai-config.js');
+const clientPath = require.resolve('../reader/ai-client.js');
+
+const dirs = [];
+function tmp() {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-ai-'));
+ dirs.push(d);
+ return d;
+}
+test.after(() => {
+ for (const d of dirs) {
+ try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
+ }
+});
+
+const storage = {
+ isEncryptionAvailable: () => true,
+ encryptString: (s) => Buffer.from('E' + s),
+ decryptString: (b) => b.toString().slice(1)
+};
+
+function setup({
+ protocol = 'chat-completions',
+ baseUrl = 'https://api.test.com/v1',
+ model = 'm',
+ apiKey = 'sk-1',
+ vision = false
+} = {}) {
+ delete require.cache[cfgPath];
+ delete require.cache[clientPath];
+ const cfg = require(cfgPath);
+ cfg.init(tmp(), storage);
+ cfg.save({ protocol, baseUrl, model, apiKey, vision });
+ return require(clientPath);
+}
+
+function visualContext(overrides = {}) {
+ const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
+ return {
+ kind: 'page',
+ image: {
+ mimeType: 'image/png',
+ base64,
+ width: 1,
+ height: 1,
+ bytes: Buffer.from(base64, 'base64').length
+ },
+ ocr: { status: 'idle', text: '', include: false },
+ ...overrides
+ };
+}
+
+function sseBody(chunks, { done = true } = {}) {
+ const lines = chunks.map((c) => `data: ${JSON.stringify({ choices: [{ delta: { content: c } }] })}\n\n`);
+ if (done) lines.push('data: [DONE]\n\n');
+ return lines.join('');
+}
+
+// 把字符串切成多个 chunk,模拟真实网络分片(含跨 chunk 断行)
+function streamResponse(text, { status = 200, pieces = 3 } = {}) {
+ const buf = Buffer.from(text, 'utf8');
+ const size = Math.ceil(buf.length / pieces);
+ const parts = [];
+ for (let i = 0; i < buf.length; i += size) parts.push(buf.subarray(i, i + size));
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: () => null, getSetCookie: () => [] },
+ text: async () => text,
+ json: async () => JSON.parse(text),
+ body: (async function* () { for (const p of parts) yield p; })()
+ };
+}
+
+test('流式增量按顺序回调并拼出完整文本', async () => {
+ const ai = setup();
+ h.setHandler(() => streamResponse(sseBody(['你', '好', '世界']), { pieces: 5 }));
+ const seen = [];
+ const full = await ai.stream({ task: 'translate', text: 'hello', onDelta: (d) => seen.push(d) });
+ assert.strictEqual(full, '你好世界');
+ assert.deepStrictEqual(seen, ['你', '好', '世界']);
+});
+
+test('SSE 分片跨 chunk 断开也能正确解析', async () => {
+ const ai = setup();
+ // 每个字节一个 chunk,保证 data: 行被切碎
+ h.setHandler(() => streamResponse(sseBody(['abc', 'def']), { pieces: 200 }));
+ const full = await ai.stream({ task: 'explain', text: 'x' });
+ assert.strictEqual(full, 'abcdef');
+});
+
+test('遇到 [DONE] 立即结束,不解析后续内容', async () => {
+ const ai = setup();
+ const body = sseBody(['一'], { done: true }) + sseBody(['不该出现'], { done: false });
+ h.setHandler(() => streamResponse(body));
+ assert.strictEqual(await ai.stream({ task: 'summarize', text: 'x' }), '一');
+});
+
+test('HTTP 错误体里的 message 会被提取为中文可读错误', async () => {
+ const ai = setup();
+ h.setHandler(() => streamResponse(JSON.stringify({ error: { message: 'model not found' } }), { status: 404 }));
+ await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /model not found/);
+});
+
+test('401 无 JSON 体时给出可读提示', async () => {
+ const ai = setup();
+ h.setHandler(() => streamResponse('Unauthorized', { status: 401 }));
+ await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key 无效/);
+});
+
+test('流内返回 error 字段也会抛出', async () => {
+ const ai = setup();
+ h.setHandler(() => streamResponse('data: ' + JSON.stringify({ error: { message: '额度不足' } }) + '\n\n'));
+ await assert.rejects(() => ai.stream({ task: 'ask', text: 'x', question: 'q' }), /额度不足/);
+});
+
+test('未配置 Key 且非本地端点时拒绝请求', async () => {
+ const ai = setup({ apiKey: '' });
+ await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key/);
+});
+
+test('本地端点无 Key 也允许请求,且不带 Authorization 头', async () => {
+ const ai = setup({ baseUrl: 'http://localhost:11434/v1', apiKey: '' });
+ let seenHeaders = null;
+ h.setHandler((_u, o) => { seenHeaders = o.headers; return streamResponse(sseBody(['ok'])); });
+ assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
+ assert.ok(!seenHeaders.Authorization, '本地模型不该发送 Authorization');
+});
+
+test('请求体包含模型名与 stream 标志,且 Key 放在头里', async () => {
+ const ai = setup({ model: 'deepseek-chat', apiKey: 'sk-abc' });
+ let seen = null;
+ h.setHandler((u, o) => { seen = { u, o }; return streamResponse(sseBody(['x'])); });
+ await ai.stream({ task: 'translate', text: 'hi' });
+ const body = JSON.parse(seen.o.body);
+ assert.strictEqual(body.model, 'deepseek-chat');
+ assert.strictEqual(body.stream, true);
+ assert.strictEqual(seen.o.headers.Authorization, 'Bearer sk-abc');
+ assert.ok(seen.u.endsWith('/chat/completions'), '端点拼接错误: ' + seen.u);
+ assert.ok(!seen.u.includes('sk-abc'), 'Key 不该出现在 URL 中');
+});
+
+test('启用图像输入后使用 OpenAI 兼容的 image_url 消息', async () => {
+ const ai = setup({ vision: true });
+ let body = null;
+ h.setHandler((_u, options) => {
+ body = JSON.parse(options.body);
+ return streamResponse(sseBody(['看到了']));
+ });
+ const full = await ai.stream({
+ task: 'ask',
+ text: '',
+ question: '图中是什么?',
+ visualContexts: [visualContext()]
+ });
+ assert.strictEqual(full, '看到了');
+ assert.ok(Array.isArray(body.messages[1].content));
+ assert.strictEqual(body.messages[1].content[0].type, 'text');
+ assert.strictEqual(body.messages[1].content[1].type, 'image_url');
+ assert.match(body.messages[1].content[1].image_url.url, /^data:image\/png;base64,/);
+ assert.deepStrictEqual(Object.keys(body.messages[1].content[1].image_url), ['url']);
+});
+
+test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', async () => {
+ const ai = setup({ protocol: 'anthropic', vision: true });
+ let seen = null;
+ h.setHandler((url, options) => {
+ seen = { url, options, body: JSON.parse(options.body) };
+ return streamResponse([
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '识别' } })}\n\n`,
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '成功' } })}\n\n`,
+ `event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`
+ ].join(''), { pieces: 11 });
+ });
+ const full = await ai.stream({
+ task: 'ask',
+ text: '',
+ question: '图中是什么?',
+ visualContexts: [visualContext()]
+ });
+ assert.strictEqual(full, '识别成功');
+ assert.ok(seen.url.endsWith('/messages'), seen.url);
+ assert.strictEqual(seen.options.headers['x-api-key'], 'sk-1');
+ assert.strictEqual(seen.options.headers['anthropic-version'], '2023-06-01');
+ assert.ok(!seen.options.headers.Authorization);
+ assert.strictEqual(seen.body.system.includes('文档页面图像'), true);
+ assert.strictEqual(seen.body.messages.length, 1);
+ assert.strictEqual(seen.body.messages[0].content[0].type, 'text');
+ const image = seen.body.messages[0].content[1];
+ assert.strictEqual(image.type, 'image');
+ assert.deepStrictEqual(Object.keys(image.source), ['type', 'media_type', 'data']);
+ assert.strictEqual(image.source.type, 'base64');
+ assert.strictEqual(image.source.media_type, 'image/png');
+ assert.ok(image.source.data.length > 0);
+});
+
+test('OpenAI Responses 接口使用 input_image 和响应增量事件', async () => {
+ const ai = setup({ protocol: 'openai-responses', vision: true });
+ let seen = null;
+ h.setHandler((url, options) => {
+ seen = { url, options, body: JSON.parse(options.body) };
+ return streamResponse([
+ `event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '看见' })}\n\n`,
+ `event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '图片' })}\n\n`,
+ `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`
+ ].join(''), { pieces: 13 });
+ });
+ const full = await ai.stream({
+ task: 'ask',
+ text: '',
+ question: '图中是什么?',
+ visualContexts: [visualContext()]
+ });
+ assert.strictEqual(full, '看见图片');
+ assert.ok(seen.url.endsWith('/responses'), seen.url);
+ assert.strictEqual(seen.options.headers.Authorization, 'Bearer sk-1');
+ assert.strictEqual(seen.body.instructions.includes('文档页面图像'), true);
+ assert.strictEqual(seen.body.max_output_tokens, 1024);
+ assert.strictEqual(seen.body.store, false);
+ assert.strictEqual(seen.body.input[0].content[0].type, 'input_text');
+ const image = seen.body.input[0].content[1];
+ assert.strictEqual(image.type, 'input_image');
+ assert.match(image.image_url, /^data:image\/png;base64,/);
+});
+
+test('OpenAI Responses 失败事件不会被当作空回答', async () => {
+ const ai = setup({ protocol: 'openai-responses' });
+ h.setHandler(() => streamResponse(
+ `event: response.failed\ndata: ${JSON.stringify({
+ type: 'response.failed',
+ response: { error: { message: 'responses failed' } }
+ })}\n\n`
+ ));
+ await assert.rejects(
+ () => ai.stream({ task: 'translate', text: 'x' }),
+ /responses failed/
+ );
+});
+
+test('协议端点追加在查询参数之前并保留参数', async () => {
+ const ai = setup({
+ protocol: 'openai-responses',
+ baseUrl: 'https://gateway.example.com/v1?api-version=2026-01-01'
+ });
+ let requestUrl = '';
+ h.setHandler((url) => {
+ requestUrl = url;
+ return streamResponse(
+ `data: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'ok' })}\n\n`
+ + `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
+ );
+ });
+ assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
+ const url = new URL(requestUrl);
+ assert.strictEqual(url.pathname, '/v1/responses');
+ assert.strictEqual(url.searchParams.get('api-version'), '2026-01-01');
+});
+
+test('未显式启用图像能力时拒绝发送图片', async () => {
+ const ai = setup({ vision: false });
+ await assert.rejects(
+ () => ai.stream({
+ task: 'ask',
+ text: '',
+ question: '图中是什么?',
+ visualContexts: [visualContext()]
+ }),
+ /未启用图像输入/
+ );
+});
+
+test('图像上下文拒绝伪造尺寸、远程地址和多图输入', () => {
+ const ai = setup({ vision: true });
+ const badSize = visualContext();
+ badSize.image.width = 2;
+ assert.throws(() => ai.buildMessages('ask', '', 'q', [badSize]), /声明尺寸不匹配/);
+ assert.throws(
+ () => ai.buildMessages('ask', '', 'q', [{ kind: 'page', image: { url: 'https://example.com/a.png' } }]),
+ /JPEG 或 PNG/
+ );
+ assert.throws(
+ () => ai.buildMessages('ask', '', 'q', [visualContext(), visualContext()]),
+ /最多发送 1 张/
+ );
+});
+
+test('OCR 预留契约仅在识别完成且勾选后附加文字', () => {
+ const ai = setup({ vision: true });
+ const context = visualContext({
+ ocr: { status: 'ready', text: '校对后的 OCR 内容', include: true }
+ });
+ const messages = ai.buildMessages('ask', '', '这是什么?', [context]);
+ const textPart = messages[1].content.find((part) => part.type === 'text');
+ assert.match(textPart.text, /OCR 识别文字/);
+ assert.match(textPart.text, /校对后的 OCR 内容/);
+});
+
+test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
+ const ai = setup({ vision: false });
+ let body = null;
+ h.setHandler((_url, options) => {
+ body = JSON.parse(options.body);
+ return streamResponse(sseBody(['文字回答']));
+ });
+ const context = visualContext({
+ includeImage: false,
+ ocr: { status: 'ready', text: '仅发送 OCR', include: true }
+ });
+ assert.strictEqual(await ai.stream({
+ task: 'ask',
+ text: '',
+ question: '内容是什么?',
+ visualContexts: [context]
+ }), '文字回答');
+ assert.strictEqual(typeof body.messages[1].content, 'string');
+ assert.match(body.messages[1].content, /仅发送 OCR/);
+ assert.doesNotMatch(body.messages[1].content, /data:image/);
+});
+
+test('超长上下文被截断且保留首尾', () => {
+ const ai = setup();
+ const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
+ const clipped = ai.clipContext(long, 2000);
+ assert.ok(clipped.length < long.length);
+ assert.ok(clipped.startsWith('A'), '开头丢失');
+ assert.ok(clipped.includes('TAIL_MARK'), '结尾丢失了,结论性内容会被切掉');
+ assert.ok(clipped.includes('省略'), '未标注截断');
+});
+
+test('不支持的任务类型被拒绝', () => {
+ const ai = setup();
+ assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
+});
+
+test('ask 任务把问题与片段一起送出', () => {
+ const ai = setup();
+ const msgs = ai.buildMessages('ask', '文档内容', '这讲了什么');
+ assert.strictEqual(msgs.length, 2);
+ assert.ok(msgs[1].content.includes('文档内容'));
+ assert.ok(msgs[1].content.includes('这讲了什么'));
+ assert.ok(/编造|没有提到/.test(msgs[0].content), '缺少防幻觉约束');
+});
+
+test('取消请求时抛出 AbortError 而不是静默返回', async () => {
+ const ai = setup();
+ const ctl = new AbortController();
+ h.setHandler(() => { ctl.abort(); return streamResponse(sseBody(['x'])); });
+ await assert.rejects(
+ () => ai.stream({ task: 'translate', text: 'x', signal: ctl.signal }),
+ (e) => e.name === 'AbortError'
+ );
+});
diff --git a/src/_test/annotations.test.js b/src/_test/annotations.test.js
new file mode 100644
index 0000000..376388c
--- /dev/null
+++ b/src/_test/annotations.test.js
@@ -0,0 +1,164 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+
+const modulePath = require.resolve('../reader/annotations.js');
+const dirs = [];
+
+function fresh() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-annotations-'));
+ dirs.push(dir);
+ delete require.cache[modulePath];
+ const store = require(modulePath);
+ store.init(dir);
+ return { store, dir };
+}
+
+function key(name) {
+ return crypto.createHash('sha256').update(name).digest('hex');
+}
+
+test.after(() => {
+ for (const dir of dirs) {
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
+ }
+});
+
+test('每个条目保存到独立批注文件', () => {
+ const { store, dir } = fresh();
+ store.setPage('book_a', key('a.pdf'), 1, { objects: [{ type: 'Rect', left: 10 }] });
+ store.setPage('book_b', key('b.pdf'), 2, { objects: [{ type: 'Path' }] });
+ const files = fs.readdirSync(path.join(dir, 'reader-annotations')).sort();
+ assert.deepStrictEqual(files, ['book_a.json', 'book_b.json']);
+ assert.strictEqual(store.get('book_a', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
+ assert.strictEqual(store.get('book_b', key('b.pdf')).pages['2'].objects[0].type, 'Path');
+});
+
+test('文档指纹在文件移动后保持稳定,内容变化后更新', () => {
+ const { store, dir } = fresh();
+ const first = path.join(dir, 'first.pdf');
+ const moved = path.join(dir, 'moved.pdf');
+ const bytes = Buffer.alloc(256 * 1024, 1);
+ fs.writeFileSync(first, bytes);
+ const before = store.documentKey(first);
+ fs.renameSync(first, moved);
+ assert.strictEqual(store.documentKey(moved), before);
+ bytes[128 * 1024] = 2;
+ fs.writeFileSync(moved, bytes);
+ assert.notStrictEqual(store.documentKey(moved), before);
+});
+
+test('大文档指纹只采样首中尾且小文档保持完整 SHA-256', () => {
+ const { store, dir } = fresh();
+ const small = path.join(dir, 'small.pdf');
+ const smallBytes = Buffer.alloc(4096, 7);
+ fs.writeFileSync(small, smallBytes);
+ assert.strictEqual(
+ store.hashDocumentFile(small, smallBytes.length, 8192),
+ crypto.createHash('sha256').update(smallBytes).digest('hex')
+ );
+
+ const large = path.join(dir, 'large.pdf');
+ const largeBytes = Buffer.alloc(12 * 1024 * 1024, 3);
+ fs.writeFileSync(large, largeBytes);
+ const before = store.hashDocumentFile(large, largeBytes.length, 1024 * 1024);
+ const fd = fs.openSync(large, 'r+');
+ try {
+ fs.writeSync(fd, Buffer.from([9]), 0, 1, 6 * 1024 * 1024);
+ } finally {
+ fs.closeSync(fd);
+ }
+ const after = store.hashDocumentFile(large, largeBytes.length, 1024 * 1024);
+ assert.notStrictEqual(after, before);
+});
+
+test('生成指纹期间文件持续变化时拒绝返回混合版本标识', () => {
+ const { store, dir } = fresh();
+ const file = path.join(dir, 'changing.pdf');
+ fs.writeFileSync(file, Buffer.alloc(4096, 1));
+ const originalStat = fs.statSync;
+ let calls = 0;
+ fs.statSync = function (target, ...args) {
+ const stat = originalStat.call(fs, target, ...args);
+ if (path.resolve(String(target)) === path.resolve(file)) {
+ Object.defineProperty(stat, 'mtimeMs', { value: stat.mtimeMs + calls++ });
+ }
+ return stat;
+ };
+ try {
+ assert.throws(() => store.documentKey(file), /生成指纹期间发生变化/);
+ } finally {
+ fs.statSync = originalStat;
+ }
+});
+
+test('同一条目的不同 PDF 文件与页码互相隔离', () => {
+ const { store } = fresh();
+ store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'IText', text: 'A' }] });
+ store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'IText', text: 'B' }] });
+ store.setPage('book', key('a.pdf'), 2, { objects: [{ type: 'Rect' }] });
+ assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].text, 'A');
+ assert.strictEqual(store.get('book', key('b.pdf')).pages['1'].objects[0].text, 'B');
+ assert.strictEqual(store.get('book', key('a.pdf')).pages['2'].objects[0].type, 'Rect');
+});
+
+test('空对象列表删除当前页但保留其它页', () => {
+ const { store } = fresh();
+ store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
+ store.setPage('book', key('a.pdf'), 2, { objects: [{ type: 'Path' }] });
+ store.setPage('book', key('a.pdf'), 1, { objects: [] });
+ const pages = store.get('book', key('a.pdf')).pages;
+ assert.strictEqual(pages['1'], undefined);
+ assert.strictEqual(pages['2'].objects.length, 1);
+});
+
+test('get 返回深拷贝,外部修改不污染缓存文件', () => {
+ const { store } = fresh();
+ store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect', left: 5 }] });
+ const first = store.get('book', key('a.pdf'));
+ first.pages['1'].objects[0].left = 999;
+ assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].left, 5);
+});
+
+test('拒绝路径穿越、非法页码和异常大的单页数据', () => {
+ const { store } = fresh();
+ assert.throws(() => store.get('../outside', key('a.pdf')), /ID/);
+ assert.throws(() => store.setPage('book', 'bad', 1, { objects: [] }), /标识/);
+ assert.throws(() => store.setPage('book', key('a.pdf'), 0, { objects: [] }), /页码/);
+ assert.throws(() => store.setPage('book', key('a.pdf'), 1, { objects: [{ text: 'x'.repeat(2 * 1024 * 1024) }] }), /过大/);
+});
+
+test('损坏文件回退为空,后续写入可恢复', () => {
+ const { store, dir } = fresh();
+ const folder = path.join(dir, 'reader-annotations');
+ fs.mkdirSync(folder, { recursive: true });
+ fs.writeFileSync(path.join(folder, 'book.json'), '{ bad');
+ assert.deepStrictEqual(store.get('book', key('a.pdf')).pages, {});
+ assert.ok(fs.readdirSync(folder).some((name) => name.startsWith('book.json.corrupt-')));
+ store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
+ assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects.length, 1);
+});
+
+test('主文件损坏时优先从原子写入备份恢复', () => {
+ const { store, dir } = fresh();
+ store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
+ const file = path.join(dir, 'reader-annotations', 'book.json');
+ fs.copyFileSync(file, `${file}.bak`);
+ fs.writeFileSync(file, '{ bad');
+ assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
+});
+
+test('forget 删除条目批注及备份残留', () => {
+ const { store, dir } = fresh();
+ store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
+ const file = path.join(dir, 'reader-annotations', 'book.json');
+ fs.writeFileSync(`${file}.bak`, '{}');
+ fs.writeFileSync(`${file}.corrupt-1`, '{ bad');
+ assert.strictEqual(store.forget('book'), true);
+ assert.strictEqual(fs.existsSync(file), false);
+ assert.strictEqual(fs.existsSync(`${file}.bak`), false);
+ assert.strictEqual(fs.existsSync(`${file}.corrupt-1`), false);
+});
diff --git a/src/_test/auth.test.js b/src/_test/auth.test.js
new file mode 100644
index 0000000..27ce338
--- /dev/null
+++ b/src/_test/auth.test.js
@@ -0,0 +1,191 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const authPath = require.resolve('../sources/zlib-auth.js');
+const keyPath = require.resolve('../sources/semantic-key.js');
+
+const dirs = [];
+function tmp() {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-auth-'));
+ dirs.push(d);
+ return d;
+}
+test.after(() => {
+ for (const d of dirs) {
+ try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
+ }
+});
+
+// 模拟 Electron safeStorage:加密就是加个前缀 + base64,能验证"没有明文落盘"
+function fakeStorage(available = true) {
+ return {
+ isEncryptionAvailable: () => available,
+ encryptString: (s) => Buffer.from('ENC:' + Buffer.from(s, 'utf8').toString('base64')),
+ decryptString: (buf) => {
+ const s = buf.toString();
+ if (!s.startsWith('ENC:')) throw new Error('bad ciphertext');
+ return Buffer.from(s.slice(4), 'base64').toString('utf8');
+ }
+ };
+}
+
+function freshAuth() {
+ delete require.cache[authPath];
+ return require(authPath);
+}
+
+test('凭据加密落盘,磁盘上没有明文密码', () => {
+ const d = tmp();
+ const auth = freshAuth();
+ auth.init(d, fakeStorage());
+ auth.write({ email: 'me@example.com', password: 'SuperSecret123', userId: '7', userKey: 'k' });
+
+ const all = fs.readdirSync(d).map((f) => fs.readFileSync(path.join(d, f)).toString());
+ for (const content of all) {
+ assert.ok(!content.includes('SuperSecret123'), '磁盘上出现了明文密码: ' + content.slice(0, 120));
+ assert.ok(!content.includes(Buffer.from('SuperSecret123').toString('base64')),
+ '密码只做了 base64 混淆');
+ }
+ const back = auth.read();
+ assert.strictEqual(back.password, 'SuperSecret123');
+ assert.strictEqual(back.email, 'me@example.com');
+ assert.strictEqual(back.userId, '7');
+});
+
+test('旧版 base64 数据自动迁移并抹掉明文', () => {
+ const d = tmp();
+ const legacy = {
+ email: Buffer.from('old@example.com').toString('base64'),
+ password: Buffer.from('OldPass').toString('base64'),
+ userId: '1', userKey: 'ukey', mirror: 'https://z-lib.fm'
+ };
+ fs.writeFileSync(path.join(d, 'zlib-auth.json'), JSON.stringify(legacy));
+
+ const auth = freshAuth();
+ auth.init(d, fakeStorage());
+ const c = auth.read();
+ assert.strictEqual(c.email, 'old@example.com', '迁移后邮箱丢失');
+ assert.strictEqual(c.password, 'OldPass', '迁移后密码丢失');
+ assert.strictEqual(c.userKey, 'ukey', '会话字段应保留');
+
+ const json = fs.readFileSync(path.join(d, 'zlib-auth.json'), 'utf8');
+ assert.ok(!json.includes(legacy.password), '旧的明文/混淆密码没有被抹掉');
+ assert.ok(fs.existsSync(path.join(d, 'zlib-auth.cred')), '未生成加密文件');
+});
+
+test('系统不支持加密时绝不把密码写到磁盘', () => {
+ const d = tmp();
+ const auth = freshAuth();
+ auth.init(d, fakeStorage(false));
+ auth.write({ email: 'a@b.c', password: 'PlainSecret' });
+
+ for (const f of fs.readdirSync(d)) {
+ const content = fs.readFileSync(path.join(d, f)).toString();
+ assert.ok(!content.includes('PlainSecret'), `${f} 里落了明文密码`);
+ }
+ // 本进程内仍可用
+ assert.strictEqual(auth.read().password, 'PlainSecret');
+ assert.strictEqual(auth.hasCreds(), true);
+});
+
+test('setSession 不会因为读取失败清空凭据', () => {
+ const d = tmp();
+ const auth = freshAuth();
+ auth.init(d, fakeStorage());
+ auth.write({ email: 'x@y.z', password: 'Keep', userId: '', userKey: '' });
+ auth.setSession('99', 'newkey', 'https://z-lib.fm');
+
+ const c = auth.read();
+ assert.strictEqual(c.password, 'Keep', 'setSession 吞掉了密码');
+ assert.strictEqual(c.email, 'x@y.z');
+ assert.strictEqual(c.userId, '99');
+ assert.strictEqual(c.mirror, 'https://z-lib.fm');
+
+ // setSession 只应改会话字段,绝不能把凭据顺手写进明文 meta 文件
+ const meta = fs.readFileSync(path.join(d, 'zlib-auth.json'), 'utf8');
+ assert.ok(!meta.includes('Keep'), 'setSession 把明文密码写进了 json');
+ assert.ok(!meta.includes('x@y.z'), 'setSession 把明文邮箱写进了 json');
+});
+
+test('clearSession 保留凭据,clear 全部清掉', () => {
+ const d = tmp();
+ const auth = freshAuth();
+ auth.init(d, fakeStorage());
+ auth.write({ email: 'x@y.z', password: 'Keep', userId: '1', userKey: 'k', mirror: 'm' });
+
+ auth.clearSession();
+ assert.strictEqual(auth.getSession(), null, '会话未清除');
+ assert.strictEqual(auth.hasCreds(), true, 'clearSession 不该动凭据');
+ assert.strictEqual(auth.read().password, 'Keep');
+
+ auth.clear();
+ assert.strictEqual(auth.hasCreds(), false);
+ assert.strictEqual(auth.read(), null);
+ assert.ok(!fs.existsSync(path.join(d, 'zlib-auth.cred')), '密文文件未删除');
+});
+
+test('customMirrors 往返不丢失', () => {
+ const d = tmp();
+ const auth = freshAuth();
+ auth.init(d, fakeStorage());
+ auth.write({ email: 'a@b.c', password: 'p', customMirrors: ['https://m1', 'https://m2'] });
+ assert.deepStrictEqual(auth.read().customMirrors, ['https://m1', 'https://m2']);
+});
+
+test('损坏的密文不影响会话字段读取', () => {
+ const d = tmp();
+ const auth = freshAuth();
+ auth.init(d, fakeStorage());
+ auth.write({ email: 'a@b.c', password: 'p', userId: '5', userKey: 'kk' });
+ fs.writeFileSync(path.join(d, 'zlib-auth.cred'), 'garbage');
+
+ const c = auth.read();
+ assert.strictEqual(c.password, '', '损坏密文应视为无凭据');
+ assert.strictEqual(c.userId, '5', '会话字段不该受影响');
+ assert.strictEqual(auth.hasCreds(), false);
+});
+
+test('写入是原子的,不留 .tmp 残留', () => {
+ const d = tmp();
+ const auth = freshAuth();
+ auth.init(d, fakeStorage());
+ auth.write({ email: 'a@b.c', password: 'p', userId: '1', userKey: 'k' });
+ const leftovers = fs.readdirSync(d).filter((f) => f.endsWith('.tmp'));
+ assert.deepStrictEqual(leftovers, [], '存在临时文件残留');
+});
+
+// --- semantic-key ---
+
+test('semantic-key: 解密失败不被永久缓存,可自愈', () => {
+ const d = tmp();
+ delete require.cache[keyPath];
+ const sk = require(keyPath);
+ const storage = fakeStorage();
+ sk.init(d, storage);
+ sk.write('real-api-key');
+ assert.strictEqual(sk.read(), 'real-api-key');
+
+ // 模拟一次临时读取失败(文件被占用等)
+ const file = path.join(d, 'semantic-scholar-key.bin');
+ const good = fs.readFileSync(file);
+ fs.writeFileSync(file, 'corrupted');
+ delete require.cache[keyPath];
+ const sk2 = require(keyPath);
+ sk2.init(d, storage);
+ assert.strictEqual(sk2.read(), '', '损坏时应返回空');
+ // 恢复后同一进程内必须能重新读到,不能被空值缓存钉死
+ fs.writeFileSync(file, good);
+ assert.strictEqual(sk2.read(), 'real-api-key', '临时失败被永久缓存了');
+});
+
+test('semantic-key: 未配置时稳定返回空', () => {
+ const d = tmp();
+ delete require.cache[keyPath];
+ const sk = require(keyPath);
+ sk.init(d, fakeStorage());
+ assert.strictEqual(sk.read(), '');
+ assert.strictEqual(sk.status().configured, false);
+});
diff --git a/src/_test/electron/ai-scope.integration.js b/src/_test/electron/ai-scope.integration.js
new file mode 100644
index 0000000..8c45039
--- /dev/null
+++ b/src/_test/electron/ai-scope.integration.js
@@ -0,0 +1,436 @@
+// 验证 AI 上下文范围由用户选择,且大上下文必须确认后才外发。
+// 用真实的本地 OpenAI 兼容服务接收请求,断言"实际离开进程的内容",而不是 stub 渲染层。
+const { app, BrowserWindow, clipboard, shell, nativeImage } = require('electron');
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const http = require('http');
+
+const ROOT = path.resolve(__dirname, '..', '..', '..');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'plscope-'));
+app.setPath('userData', TMP);
+app.setPath('appData', TMP);
+
+const results = [];
+function chk(name, cond, extra = '') { results.push([cond ? 'OK' : 'FAIL', name, extra]); }
+const openedExternal = [];
+const openExternalStub = async (url) => { openedExternal.push(url); };
+shell.openExternal = openExternalStub;
+if (shell.openExternal !== openExternalStub) throw new Error('无法隔离外部链接测试');
+
+const AI_MARKDOWN = [
+ '# 回答\n\n',
+ '1. **第一项**\n2. 第二项\n\n',
+ '```js\nconsole.log("safe")\n```\n\n',
+ '| 项目 | 结论 |\n| --- | --- |\n| A | 可用 |\n\n',
+ '[安全链接](https://example.com/path)\n\n',
+ '[危险链接](javascript:alert(1))\n\n',
+ ' \n\n',
+ ''
+].join('');
+
+// 真实的本地模型服务:记录每次收到的 body
+const received = [];
+const requests = [];
+const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ let parsed;
+ try { parsed = JSON.parse(body); } catch (e) { parsed = { parseError: body.slice(0, 80) }; }
+ received.push(parsed);
+ requests.push({ url: req.url, headers: req.headers, body: parsed });
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ if (req.url.endsWith('/messages')) {
+ res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Anthropic 正常' } })}\n\n`);
+ res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
+ res.end();
+ return;
+ }
+ if (req.url.endsWith('/responses')) {
+ res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'Responses 正常' })}\n\n`);
+ res.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`);
+ res.end();
+ return;
+ }
+ res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(0, 80) } }] })}\n\n`);
+ setTimeout(() => {
+ res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(80) } }] })}\n\n`);
+ res.write('data: [DONE]\n\n');
+ res.end();
+ }, 300);
+ });
+});
+
+function charsOf(request) {
+ const msgs = (request && request.messages) || [];
+ return msgs.reduce((k, m) => k + (typeof (m && m.content) === 'string' ? m.content.length : 0), 0);
+}
+
+app.whenReady().then(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const port = server.address().port;
+
+ const epubPath = path.join(os.tmpdir(), 'plscope-cache', 's.epub');
+ fs.mkdirSync(path.dirname(epubPath), { recursive: true });
+ if (!fs.existsSync(epubPath)) {
+ const { fetch: uf, ProxyAgent } = require('undici');
+ const r = await uf('https://www.gutenberg.org/ebooks/11.epub.noimages', {
+ dispatcher: new ProxyAgent({ uri: 'http://127.0.0.1:7890', connectTimeout: 30000 })
+ });
+ fs.writeFileSync(epubPath, Buffer.from(await r.arrayBuffer()));
+ }
+
+ require(path.join(ROOT, 'main.js'));
+ const settings = require(path.join(ROOT, 'src', 'settings'));
+ const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
+ const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
+ const library = require(path.join(ROOT, 'src', 'library', 'store'));
+ settings.init(TMP);
+ readerStore.init(TMP);
+ aiConfig.init(TMP, require('electron').safeStorage);
+ library.init(path.join(TMP, 'library'));
+ require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
+
+ aiConfig.save({
+ protocol: 'chat-completions',
+ baseUrl: `http://127.0.0.1:${port}/v1`,
+ model: 'test-model',
+ apiKey: '',
+ vision: true
+ });
+
+ const e = library.add({ title: 'Alice', authors: [], files: [{ path: epubPath, name: 's.epub', format: 'EPUB' }] });
+ await new Promise((r) => setTimeout(r, 2500));
+
+ for (const w of BrowserWindow.getAllWindows()) w.hide();
+
+ const win = new BrowserWindow({
+ show: false, width: 1200, height: 860,
+ webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
+ });
+ const errs = [];
+ win.webContents.on('console-message', (event) => {
+ const { level, message } = event;
+ if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
+ errs.push(message.slice(0, 120));
+ }
+ });
+ await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
+ await new Promise((r) => setTimeout(r, 9000));
+
+ const js = async (code) => {
+ try { return await win.webContents.executeJavaScript(code); }
+ catch (err) { return 'ERR ' + err.message.slice(0, 90); }
+ };
+
+ await js("(function(){var n=document.querySelectorAll('#tocList [data-idx], #tocList .toc-item, #tocList button');if(n[3])n[3].click();return n.length})()");
+ await new Promise((r) => setTimeout(r, 3000));
+
+ chk('上下文选择器存在', (await js("!!document.getElementById('aiScope')")) === true);
+ chk('默认范围是"仅选中文本"', (await js("document.getElementById('aiScope').value")) === 'selection');
+ chk('文本和图像五个范围选项齐全',
+ (await js("Array.from(document.getElementById('aiScope').options).map(o=>o.value).join(',')")) === 'selection,page,document,page-image,region-image');
+
+ await js("document.querySelector('[data-pane=\"ai\"]').click()");
+ await new Promise((r) => setTimeout(r, 800));
+ chk('未选中文本时给出提示', String(await js("document.getElementById('aiCost').textContent")).includes('未选中'));
+
+ // 打开阅读器并静置:不应有任何请求发往模型
+ await new Promise((r) => setTimeout(r, 3000));
+ chk('空闲时不会自动调用模型', received.length === 0, '请求数=' + received.length);
+
+ // 页面/全文范围不依赖选中文本:此时正文里没有任何选区
+ chk('切换范围前确实没有选中文本',
+ (await js("String(window.getSelection() ? window.getSelection().toString() : '').trim().length")) === 0);
+
+ await js("var s=document.getElementById('aiScope'); s.value='page'; s.dispatchEvent(new Event('change'));");
+ await new Promise((r) => setTimeout(r, 1500));
+ const pageCostText = String(await js("document.getElementById('aiCost').textContent"));
+ chk('未选中文本时当前页范围仍可估算', /字.*tokens/.test(pageCostText), pageCostText);
+ const pageChars = Number((/([\d,]+)\s*字/.exec(pageCostText) || [0, '0'])[1].replace(/,/g, ''));
+ chk('当前页范围估算出非空正文', pageChars > 0, '字数=' + pageChars);
+
+ await js("var s=document.getElementById('aiScope'); s.value='document'; s.dispatchEvent(new Event('change'));");
+ await new Promise((r) => setTimeout(r, 8000));
+ const costText = String(await js("document.getElementById('aiCost').textContent"));
+ chk('全文范围显示字数与 token 估算', /字.*tokens/.test(costText), costText);
+ chk('全文范围提示可能超过模型限制', /可能超过模型限制/.test(costText), costText);
+ const docChars = Number((/([\d,]+)\s*字/.exec(costText) || [0, '0'])[1].replace(/,/g, ''));
+ chk('全文范围覆盖整本而不仅当前页', docChars > pageChars * 5, `全文=${docChars} 当前页=${pageChars}`);
+
+ // 全文提问 + 用户拒绝 => 一个字都不该发出去
+ await js("document.getElementById('aiQuestion').value='这章讲了什么';document.getElementById('aiSendBtn').click();");
+ await new Promise((r) => setTimeout(r, 2500));
+ chk('大上下文会显示应用内确认框',
+ (await js("!document.getElementById('aiConfirmModal').classList.contains('hidden')")) === true);
+ const summary = String(await js(
+ "document.getElementById('aiConfirmScope').textContent+' '+document.getElementById('aiConfirmCost').textContent"
+ ));
+ chk('确认框包含范围、字数与 token 估算', /全文/.test(summary) && /字/.test(summary) && /tokens/.test(summary), summary);
+ chk('确认框明确警告全文可能超限',
+ /可能超过模型的上下文限制/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
+ chk('确认框使用应用按钮而非原生弹窗',
+ (await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
+ const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
+ fs.writeFileSync(path.join(captureDir, 'ai-send-confirmation.png'), (await win.webContents.capturePage()).toPNG());
+ await js("document.getElementById('aiConfirmCancelBtn').click()");
+ await new Promise((r) => setTimeout(r, 500));
+ chk('用户拒绝后没有任何外发请求', received.length === 0, '请求数=' + received.length);
+ chk('提问框内容在取消后保留', (await js("document.getElementById('aiQuestion').value")) === '这章讲了什么');
+
+ // 用户同意 => 才真正发送全文
+ await js("document.getElementById('aiSendBtn').click();");
+ await new Promise((r) => setTimeout(r, 1200));
+ await js("document.getElementById('aiConfirmSendBtn').click()");
+ await new Promise((r) => setTimeout(r, 180));
+ chk('流式传输过程中稳定渲染不完整 Markdown', await js(`(() => {
+ const output = document.getElementById('aiOutput');
+ return output.classList.contains('streaming')
+ && output.querySelector('h1')?.textContent === '回答'
+ && output.textContent.length > 0;
+ })()`));
+ await new Promise((r) => setTimeout(r, 3820));
+ chk('用户同意后发送且仅一次', received.length === 1, '请求数=' + received.length);
+ chk('外发内容为全文正文', charsOf(received[0]) > 1000, '字符=' + charsOf(received[0]));
+ chk('超长全文按上限截断后才外发', charsOf(received[0]) <= 12000 + 2000, '字符=' + charsOf(received[0]));
+ chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
+ const output = document.getElementById('aiOutput');
+ return output.querySelector('h1')?.textContent === '回答'
+ && output.querySelector('strong')?.textContent === '第一项'
+ && output.querySelectorAll('ol > li').length === 2
+ && output.querySelector('pre code')?.textContent.includes('console.log')
+ && output.querySelectorAll('table th').length === 2;
+ })()`));
+ chk('Markdown 链接和图片执行安全策略', await js(`(() => {
+ const output = document.getElementById('aiOutput');
+ const safe = output.querySelector('a[data-external-url]');
+ return safe?.dataset.externalUrl === 'https://example.com/path'
+ && safe.getAttribute('href') === '#'
+ && !output.querySelector('a[href^="javascript:"], img, script, iframe, object')
+ && !!output.querySelector('.ai-md-image-placeholder')
+ && window.__aiXss !== true;
+ })()`));
+ await js("document.querySelector('#aiOutput a[data-external-url]').click()");
+ await new Promise((r) => setTimeout(r, 200));
+ chk('安全链接通过主进程校验后打开', openedExternal.join(',') === 'https://example.com/path');
+ await js("document.getElementById('aiCopyBtn').click()");
+ await new Promise((r) => setTimeout(r, 200));
+ chk('复制 AI 回答保留原始 Markdown', clipboard.readText() === AI_MARKDOWN);
+
+ await js("var s=document.getElementById('aiScope'); s.value='page-image'; s.dispatchEvent(new Event('change'));");
+ await new Promise((r) => setTimeout(r, 2000));
+ const pageVisual = await js(`(() => {
+ const card = document.getElementById('aiVisualCard');
+ const image = document.getElementById('aiVisualPreview');
+ return {
+ visible: !card.classList.contains('hidden'),
+ source: image.getAttribute('src') || '',
+ meta: document.getElementById('aiVisualMeta').textContent,
+ ocrDisabled: document.getElementById('aiOcrBtn').disabled
+ };
+ })()`);
+ chk('当前页面图像生成内存预览并保留 OCR 入口',
+ pageVisual.visible
+ && pageVisual.source.startsWith('data:image/jpeg;base64,')
+ && /\d+ × \d+/.test(pageVisual.meta)
+ && pageVisual.ocrDisabled);
+ await js("document.getElementById('aiQuestion').value='这张页面图像讲了什么';document.getElementById('aiSendBtn').click();");
+ await new Promise((r) => setTimeout(r, 600));
+ chk('发送图像前明确显示上传尺寸和数量', await js(`(() => {
+ const modal = document.getElementById('aiConfirmModal');
+ return !modal.classList.contains('hidden')
+ && document.getElementById('aiConfirmScope').textContent.includes('图像')
+ && document.getElementById('aiConfirmCost').textContent.includes('1 张图像');
+ })()`));
+ await js("document.getElementById('aiConfirmSendBtn').click()");
+ await new Promise((r) => setTimeout(r, 4000));
+ const pageContent = received[1] && received[1].messages && received[1].messages[1].content;
+ const pageImage = Array.isArray(pageContent)
+ ? pageContent.find((part) => part && part.type === 'image_url')
+ : null;
+ const pageImageUrl = pageImage && pageImage.image_url && pageImage.image_url.url;
+ const pageImageBytes = typeof pageImageUrl === 'string'
+ ? Buffer.from(pageImageUrl.slice(pageImageUrl.indexOf(',') + 1), 'base64')
+ : Buffer.alloc(0);
+ chk('当前页面仅以内嵌受限图像发送给视觉模型',
+ received.length === 2
+ && /^data:image\/jpeg;base64,/.test(pageImageUrl || '')
+ && pageImageBytes.length > 100
+ && pageImageBytes.length <= 3 * 1024 * 1024);
+ const pageImageSize = nativeImage.createFromBuffer(pageImageBytes).getSize();
+ chk('页面图像压到目标体积以内并限制在 1600px',
+ pageImageBytes.length <= 400 * 1024
+ && Math.max(pageImageSize.width, pageImageSize.height) <= 1600,
+ `${pageImageSize.width}x${pageImageSize.height} ${Math.round(pageImageBytes.length / 1024)}KB`);
+
+ await js("var s=document.getElementById('aiScope'); s.value='region-image'; s.dispatchEvent(new Event('change'));");
+ await new Promise((r) => setTimeout(r, 500));
+ const selectionReady = await js(`(() => {
+ const overlay = document.querySelector('.visual-select-overlay');
+ const viewport = document.querySelector('.epub-scroll').getBoundingClientRect();
+ if (!overlay) return false;
+ const x1 = viewport.left + 80;
+ const y1 = viewport.top + 100;
+ const x2 = Math.min(viewport.right - 40, x1 + 300);
+ const y2 = Math.min(viewport.bottom - 40, y1 + 220);
+ overlay.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 41, button: 0, buttons: 1, clientX: x1, clientY: y1 }));
+ overlay.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerId: 41, buttons: 1, clientX: x2, clientY: y2 }));
+ overlay.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 41, button: 0, clientX: x2, clientY: y2 }));
+ const box = overlay.querySelector('.visual-select-box');
+ const initial = box.getBoundingClientRect();
+ box.dispatchEvent(new PointerEvent('pointerdown', {
+ bubbles: true, pointerId: 42, button: 0, buttons: 1,
+ clientX: initial.left + initial.width / 2, clientY: initial.top + initial.height / 2
+ }));
+ overlay.dispatchEvent(new PointerEvent('pointermove', {
+ bubbles: true, pointerId: 42, buttons: 1,
+ clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
+ }));
+ overlay.dispatchEvent(new PointerEvent('pointerup', {
+ bubbles: true, pointerId: 42, button: 0,
+ clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
+ }));
+ const moved = box.getBoundingClientRect();
+ const handle = box.querySelector('.handle-se');
+ handle.dispatchEvent(new PointerEvent('pointerdown', {
+ bubbles: true, pointerId: 43, button: 0, buttons: 1,
+ clientX: moved.right, clientY: moved.bottom
+ }));
+ overlay.dispatchEvent(new PointerEvent('pointermove', {
+ bubbles: true, pointerId: 43, buttons: 1,
+ clientX: moved.right + 20, clientY: moved.bottom + 16
+ }));
+ overlay.dispatchEvent(new PointerEvent('pointerup', {
+ bubbles: true, pointerId: 43, button: 0,
+ clientX: moved.right + 20, clientY: moved.bottom + 16
+ }));
+ const resized = box.getBoundingClientRect();
+ return !overlay.querySelector('.visual-select-actions').classList.contains('hidden')
+ && overlay.querySelectorAll('.visual-select-handle').length === 4
+ && moved.left > initial.left
+ && moved.top > initial.top
+ && resized.width > moved.width
+ && resized.height > moved.height;
+ })()`);
+ chk('框选区域支持创建、移动及四角调整', selectionReady);
+ await js("document.querySelector('.visual-select-actions .tb-btn').click()");
+ await new Promise((r) => setTimeout(r, 2000));
+ const regionVisual = await js(`(() => ({
+ visible: !document.getElementById('aiVisualCard').classList.contains('hidden'),
+ label: document.getElementById('aiVisualLabel').textContent,
+ meta: document.getElementById('aiVisualMeta').textContent,
+ overlayGone: !document.querySelector('.visual-select-overlay')
+ }))()`);
+ chk('确认框选后恢复 AI 面板并显示区域预览',
+ regionVisual.visible && regionVisual.label === '框选区域' && regionVisual.overlayGone);
+ await js("document.getElementById('aiQuestion').value='这个框选区域是什么';document.getElementById('aiSendBtn').click();");
+ await new Promise((r) => setTimeout(r, 600));
+ await js("document.getElementById('aiConfirmSendBtn').click()");
+ await new Promise((r) => setTimeout(r, 4000));
+ const regionContent = received[2] && received[2].messages && received[2].messages[1].content;
+ const regionImage = Array.isArray(regionContent)
+ ? regionContent.find((part) => part && part.type === 'image_url')
+ : null;
+ chk('框选区域作为单张图像上下文发送', received.length === 3
+ && /^data:image\/jpeg;base64,/.test(regionImage?.image_url?.url || ''));
+
+ chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
+ const output = document.getElementById('aiOutput');
+ const text = 'x'.repeat(256 * 1024 + 1);
+ window.AiMarkdown.mount(output, text);
+ return output.classList.contains('ai-output-plain')
+ && output.textContent.length === text.length
+ && output.children.length === 0;
+ })()`));
+
+ const saved = await js("window.api.settings.get('reader.aiScope','selection').then(r=>r.data)");
+ chk('范围选择已持久化', saved === 'region-image', String(saved));
+
+ // 旧版本存过 chapter,升级后必须迁移到 document,而不是回落成 selection
+ await js("window.api.settings.set('reader.aiScope','chapter')");
+ const migrationWin = new BrowserWindow({
+ show: false, width: 1200, height: 860,
+ webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
+ });
+ await migrationWin.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
+ await new Promise((r) => setTimeout(r, 9000));
+ const migratedValue = await migrationWin.webContents.executeJavaScript("document.getElementById('aiScope').value");
+ const migratedSaved = await migrationWin.webContents.executeJavaScript(
+ "window.api.settings.get('reader.aiScope','selection').then(r=>r.data)"
+ );
+ chk('旧 chapter 设置迁移为全文', migratedValue === 'document' && migratedSaved === 'document',
+ `${migratedValue}/${migratedSaved}`);
+ migrationWin.destroy();
+
+ const regionDataUrl = regionImage?.image_url?.url || '';
+ const encoded = regionDataUrl.slice(regionDataUrl.indexOf(',') + 1);
+ const imageBytes = Buffer.from(encoded, 'base64');
+ const imageSize = nativeImage.createFromBuffer(imageBytes).getSize();
+ const visualContext = {
+ kind: 'region',
+ includeImage: true,
+ image: {
+ mimeType: 'image/jpeg',
+ base64: encoded,
+ width: imageSize.width,
+ height: imageSize.height,
+ bytes: imageBytes.length
+ },
+ ocr: { status: 'idle', text: '', include: false }
+ };
+ const aiClient = require(path.join(ROOT, 'src', 'reader', 'ai-client'));
+
+ aiConfig.save({
+ protocol: 'anthropic',
+ baseUrl: `http://127.0.0.1:${port}/v1`,
+ model: 'claude-fixture',
+ apiKey: '',
+ vision: true
+ });
+ const anthropicText = await aiClient.stream({
+ task: 'ask',
+ text: '',
+ question: '测试 Anthropic 图片',
+ visualContexts: [visualContext]
+ });
+ const anthropicRequest = requests.at(-1);
+ const anthropicImage = anthropicRequest?.body?.messages?.[0]?.content?.[1];
+ chk('Anthropic Messages API 真实请求使用 base64 source',
+ anthropicText === 'Anthropic 正常'
+ && anthropicRequest?.url === '/v1/messages'
+ && anthropicRequest?.headers?.['anthropic-version'] === '2023-06-01'
+ && anthropicImage?.type === 'image'
+ && anthropicImage?.source?.type === 'base64'
+ && anthropicImage?.source?.media_type === 'image/jpeg');
+
+ aiConfig.save({
+ protocol: 'openai-responses',
+ baseUrl: `http://127.0.0.1:${port}/v1`,
+ model: 'responses-fixture',
+ apiKey: '',
+ vision: true
+ });
+ const responsesText = await aiClient.stream({
+ task: 'ask',
+ text: '',
+ question: '测试 Responses 图片',
+ visualContexts: [visualContext]
+ });
+ const responsesRequest = requests.at(-1);
+ const responsesImage = responsesRequest?.body?.input?.[0]?.content?.[1];
+ chk('OpenAI Responses API 真实请求使用 input_image',
+ responsesText === 'Responses 正常'
+ && responsesRequest?.url === '/v1/responses'
+ && responsesImage?.type === 'input_image'
+ && /^data:image\/jpeg;base64,/.test(responsesImage?.image_url || ''));
+
+ chk('无渲染层报错', errs.length === 0, errs.slice(0, 2).join(' | '));
+
+ console.log('\n========== AI 上下文控制验证 ==========');
+ for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
+ const bad = results.filter((r) => r[0] === 'FAIL').length;
+ console.log(`\n通过 ${results.length - bad}/${results.length}`);
+ server.close();
+ app.exit(bad ? 1 : 0);
+}).catch((e) => { console.error('异常:', e); app.exit(1); });
diff --git a/src/_test/electron/annotation.integration.js b/src/_test/electron/annotation.integration.js
new file mode 100644
index 0000000..00c5cc4
--- /dev/null
+++ b/src/_test/electron/annotation.integration.js
@@ -0,0 +1,427 @@
+const { app, BrowserWindow, safeStorage } = require('electron');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..', '..');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-annotation-ui-'));
+const PDF_CACHE = path.join(os.tmpdir(), 'peoplelib-fixtures', 'dummy.pdf');
+app.setPath('userData', TMP);
+app.setPath('appData', TMP);
+
+const results = [];
+function check(name, condition, detail = '') {
+ results.push([condition ? 'OK' : 'FAIL', name, detail]);
+}
+
+async function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function ensurePdf() {
+ if (fs.existsSync(PDF_CACHE)) return;
+ fs.mkdirSync(path.dirname(PDF_CACHE), { recursive: true });
+ const { fetch, ProxyAgent } = require('undici');
+ 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 })
+ });
+ if (!response.ok) throw new Error(`PDF 下载失败:${response.status}`);
+ fs.writeFileSync(PDF_CACHE, Buffer.from(await response.arrayBuffer()));
+}
+
+async function openReader(entryId) {
+ const win = new BrowserWindow({
+ show: false,
+ width: 1280,
+ height: 900,
+ webPreferences: {
+ preload: path.join(ROOT, 'preload.js'),
+ contextIsolation: true,
+ nodeIntegration: false
+ }
+ });
+ const errors = [];
+ win.webContents.on('console-message', (event) => {
+ const { level, message } = event;
+ if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
+ errors.push(message);
+ console.error('RENDERER:', message);
+ }
+ });
+ await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), {
+ query: { entryId }
+ });
+ await wait(7000);
+ return { win, errors };
+}
+
+async function js(win, source) {
+ try {
+ return await win.webContents.executeJavaScript(source);
+ } catch (error) {
+ console.error('脚本失败:', source.slice(0, 180), error.message);
+ throw error;
+ }
+}
+
+async function tool(win, name) {
+ await js(win, `document.querySelector('[data-annotation-tool="${name}"]').click()`);
+ await wait(150);
+}
+
+async function drag(win, x1, y1, x2, y2) {
+ await js(win, `(() => {
+ const canvas = document.querySelector('.pdfx-annotation .upper-canvas');
+ const rect = canvas.getBoundingClientRect();
+ const fire = (type, x, y, buttons) => canvas.dispatchEvent(new MouseEvent(type, {
+ bubbles: true, cancelable: true, button: 0, buttons,
+ clientX: rect.left + x, clientY: rect.top + y
+ }));
+ fire('mousedown', ${x1}, ${y1}, 1);
+ fire('mousemove', ${x2}, ${y2}, 1);
+ fire('mouseup', ${x2}, ${y2}, 0);
+ })()`);
+ await wait(350);
+}
+
+async function clickCanvas(win, x, y) {
+ await drag(win, x, y, x, y);
+}
+
+async function fireTouch(win, type, points, changedPoints = points) {
+ return js(win, `(() => {
+ const target = document.querySelector('.pdfx-annotation .upper-canvas');
+ const rect = target.getBoundingClientRect();
+ const make = (point) => new Touch({
+ identifier: point.id,
+ target,
+ clientX: rect.left + point.x,
+ clientY: rect.top + point.y,
+ screenX: rect.left + point.x,
+ screenY: rect.top + point.y,
+ pageX: rect.left + point.x,
+ pageY: rect.top + point.y,
+ radiusX: 2,
+ radiusY: 2,
+ force: 1
+ });
+ const touches = ${JSON.stringify(points)}.map(make);
+ const changedTouches = ${JSON.stringify(changedPoints)}.map(make);
+ const event = new TouchEvent(${JSON.stringify(type)}, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ touches,
+ targetTouches: touches,
+ changedTouches
+ });
+ target.dispatchEvent(event);
+ return event.defaultPrevented;
+ })()`);
+}
+
+app.whenReady().then(async () => {
+ await ensurePdf();
+ require(path.join(ROOT, 'main.js'));
+
+ const settings = require(path.join(ROOT, 'src', 'settings'));
+ const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
+ const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
+ const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
+ const library = require(path.join(ROOT, 'src', 'library', 'store'));
+ settings.init(TMP);
+ readerStore.init(TMP);
+ annotations.init(TMP);
+ aiConfig.init(TMP, safeStorage);
+ library.init(path.join(TMP, 'library'));
+ require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
+
+ const entry = library.add({
+ title: 'Annotation Fixture',
+ authors: [],
+ files: [{ path: PDF_CACHE, name: 'dummy.pdf', format: 'PDF' }]
+ });
+ await wait(1500);
+ for (const window of BrowserWindow.getAllWindows()) window.hide();
+
+ const first = await openReader(entry.id);
+ const win = first.win;
+ check('PDF 页面成功渲染', await js(win, "!!document.querySelector('.pdfx-page .pdfx-canvas')"));
+ check('批注入口仅在 PDF 中显示', !(await js(win, "document.getElementById('annotationToggleBtn').classList.contains('hidden')")));
+ check('右上角提供界面主题按钮', await js(win, `(() => {
+ const button = document.getElementById('uiThemeBtn');
+ return !!button && !!button.querySelector('svg') && button.title === '切换到明亮主题';
+ })()`));
+ check('阅读器默认使用暗色界面', (await js(win, "document.documentElement.dataset.uiTheme")) === 'dark');
+ const documentTheme = await js(win, "document.getElementById('themeSelect').value");
+ await js(win, "document.getElementById('uiThemeBtn').click()");
+ await wait(300);
+ check('主题按钮可切换为明亮界面', await js(win, `document.documentElement.dataset.uiTheme === 'light'
+ && document.getElementById('uiThemeBtn').title === '切换到暗色主题'
+ && getComputedStyle(document.body).color === 'rgb(31, 41, 55)'`));
+ check('界面主题不改变文档阅读主题',
+ (await js(win, "document.getElementById('themeSelect').value")) === documentTheme);
+ check('界面主题选择已持久化', settings.get('reader.uiTheme', 'dark') === 'light');
+
+ await js(win, "document.getElementById('annotationToggleBtn').click()");
+ await wait(300);
+ check('批注工具栏可展开', !(await js(win, "document.getElementById('annotationToolbar').classList.contains('hidden')")));
+ check('完整工具齐全', (await js(win, "document.querySelectorAll('[data-annotation-tool]').length")) === 8);
+ check('批注工具使用纯图标并提供悬浮提示', await js(win, `Array.from(
+ document.querySelectorAll('[data-annotation-tool]')
+ ).every(button => button.querySelector('svg') && !button.textContent.trim()
+ && button.title && button.getAttribute('aria-label'))`));
+ check('撤销、重做、清空与入口均使用提示图标', await js(win, `[
+ 'annotationUndoBtn','annotationRedoBtn','annotationClearBtn','annotationToggleBtn'
+ ].every(id => {
+ const button = document.getElementById(id);
+ return button.querySelector('svg') && !button.textContent.trim()
+ && button.title && button.getAttribute('aria-label');
+ })`));
+ check('手形工具默认启用且不遮挡页面',
+ await js(win, `document.querySelector('[data-annotation-tool="pan"]').classList.contains('active')
+ && document.querySelector('.pdfx-scroller').classList.contains('pdfx-tool-pan')
+ && getComputedStyle(document.querySelector('.pdfx-annotation')).pointerEvents === 'none'
+ && getComputedStyle(document.querySelector('.pdfx-text span')).userSelect === 'none'`));
+ const panResult = await js(win, `(() => {
+ const scroller = document.querySelector('.pdfx-scroller');
+ const page = document.querySelector('.pdfx-page');
+ scroller.scrollTop = Math.min(180, scroller.scrollHeight - scroller.clientHeight);
+ const before = scroller.scrollTop;
+ const rect = page.getBoundingClientRect();
+ const fire = (target, type, x, y, buttons) => target.dispatchEvent(new PointerEvent(type, {
+ bubbles: true, cancelable: true, pointerId: 17, pointerType: 'mouse',
+ button: 0, buttons, clientX: rect.left + x, clientY: rect.top + y
+ }));
+ fire(page, 'pointerdown', 200, 300, 1);
+ fire(scroller, 'pointermove', 200, 360, 1);
+ fire(scroller, 'pointerup', 200, 360, 0);
+ return { before, after: scroller.scrollTop };
+ })()`);
+ check('手形工具可拖拽 PDF 页面', panResult.after < panResult.before,
+ `${panResult.before} -> ${panResult.after}`);
+ await tool(win, 'text-select');
+ check('文本指针工具恢复正文选择且使用独立图标',
+ await js(win, `document.querySelector('[data-annotation-tool="text-select"]').classList.contains('active')
+ && document.querySelector('.pdfx-scroller').classList.contains('pdfx-tool-text-select')
+ && getComputedStyle(document.querySelector('.pdfx-text span')).userSelect === 'text'`));
+
+ await tool(win, 'rectangle');
+ await drag(win, 100, 100, 250, 190);
+ check('矩形工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('1 项'));
+
+ await js(win, "document.getElementById('annotationColor').value='#00aa00';document.getElementById('annotationColor').dispatchEvent(new Event('change'))");
+ await js(win, "document.getElementById('annotationWidth').value='5';document.getElementById('annotationWidth').dispatchEvent(new Event('change'))");
+ await tool(win, 'pen');
+ await drag(win, 120, 240, 280, 280);
+ check('画笔工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('2 项'));
+
+ await tool(win, 'highlight');
+ await drag(win, 140, 320, 330, 320);
+ check('高亮工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
+
+ await tool(win, 'text');
+ await clickCanvas(win, 340, 130);
+ win.webContents.insertText('批注文本');
+ await wait(200);
+ win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
+ win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
+ await wait(500);
+ check('文本工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
+
+ await tool(win, 'select');
+ await clickCanvas(win, 102, 102);
+ await js(win, "document.getElementById('progressRange').dispatchEvent(new Event('change'))");
+ await wait(500);
+ const beforeStyleSync = annotations.get(entry.id, annotations.documentKey(PDF_CACHE)).pages['1'].objects;
+ check('状态刷新不会误改旧选中批注的样式',
+ beforeStyleSync.some((object) => object.annotationKind === 'rectangle' && object.stroke === '#ff4d4f'));
+ await drag(win, 350, 140, 390, 170);
+ check('选择工具可移动批注', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
+
+ await tool(win, 'eraser');
+ await clickCanvas(win, 102, 102);
+ check('橡皮工具删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
+
+ await js(win, "document.getElementById('annotationUndoBtn').click()");
+ await wait(400);
+ check('撤销恢复删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
+ await js(win, "document.getElementById('annotationRedoBtn').click()");
+ await wait(400);
+ check('重做再次删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
+
+ const beforeWidth = await js(win, "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
+ await js(win, "document.getElementById('zoomInBtn').click()");
+ await wait(2500);
+ const afterWidth = await js(win, "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
+ check('缩放后批注层同步缩放', afterWidth > beforeWidth, `${beforeWidth} -> ${afterWidth}`);
+ check('缩放后撤销历史仍保留', !(await js(win, "document.getElementById('annotationUndoBtn').disabled")));
+
+ await wait(1000);
+ const stored = annotations.get(entry.id, annotations.documentKey(PDF_CACHE));
+ const objects = stored.pages['1'] && stored.pages['1'].objects;
+ check('批注写入 data 对应文件', Array.isArray(objects) && objects.length === 3, `对象=${objects && objects.length}`);
+ check('画笔、高亮和文本类型被持久化',
+ ['pen', 'highlight', 'text'].every((kind) => objects.some((object) => object.annotationKind === kind)));
+ check('编辑后的文本内容被持久化',
+ objects.some((object) => object.annotationKind === 'text' && object.text === '批注文本'));
+ check('颜色与粗细设置写入新批注',
+ objects.some((object) => object.annotationKind === 'pen' && object.stroke === '#00aa00' && object.strokeWidth === 5));
+ const annotationFile = path.join(TMP, 'reader-annotations', `${entry.id}.json`);
+ check('批注文件位于 reader-annotations 目录', fs.existsSync(annotationFile), annotationFile);
+
+ await tool(win, 'text');
+ await clickCanvas(win, 460, 210);
+ win.webContents.insertText('立即关闭也保存');
+ win.close();
+ await wait(900);
+ const second = await openReader(entry.id);
+ check('重开阅读器后恢复明亮界面',
+ (await js(second.win, "document.documentElement.dataset.uiTheme")) === 'light');
+ await js(second.win, "document.getElementById('annotationToggleBtn').click()");
+ await wait(500);
+ check('编辑文本后立即关闭仍保存最后状态',
+ (await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
+ await js(second.win, "document.getElementById('annotationClearBtn').click()");
+ await wait(200);
+ check('清空本页使用应用内确认框',
+ !(await js(second.win, "document.getElementById('annotationClearModal').classList.contains('hidden')")));
+ await js(second.win, "document.getElementById('annotationClearCancelBtn').click()");
+ check('取消清空保留全部批注',
+ (await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
+ await js(second.win, "document.getElementById('annotationClearBtn').click();document.getElementById('annotationClearConfirmBtn').click()");
+ await wait(350);
+ check('确认清空删除当前页批注',
+ (await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('0 项'));
+ await js(second.win, "document.getElementById('annotationUndoBtn').click()");
+ await wait(350);
+ check('清空后可撤销恢复',
+ (await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
+ await js(second.win, "document.querySelector('[data-pane=\"annotations\"]').click()");
+ check('标注页签列出已标注页面', await js(second.win, `(() => {
+ const row = document.querySelector('#annotationList .list-item');
+ return !!row && row.textContent.includes('第 1 页') && row.textContent.includes('4 项标注');
+ })()`));
+
+ await js(second.win, `document.querySelector('[data-pane="notes"]').click();
+ document.getElementById('addNoteBtn').click();
+ document.querySelector('#noteTypeChooser [data-note-type="reading"]').click();
+ document.getElementById('noteTitleInput').value = '人工笔记';
+ Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
+ .setText('通过阅读器直接记录');
+ document.getElementById('noteTagsInput').value = '集成, 手工';
+ document.getElementById('noteEditorSaveBtn').click()`);
+ await wait(500);
+ const manualNotes = readerStore.getState(entry.id).notes;
+ check('阅读器可直接新建结构化人工笔记',
+ manualNotes.some((note) => note.source === 'manual'
+ && note.title === '人工笔记'
+ && note.tags.includes('集成')));
+
+ await tool(second.win, 'text-select');
+ const selectionText = await js(second.win, `(() => {
+ const span = Array.from(document.querySelectorAll('.pdfx-text span'))
+ .find((node) => node.textContent.trim());
+ if (!span) return '';
+ const range = document.createRange();
+ range.selectNodeContents(span);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ return selection.toString().trim();
+ })()`);
+ await wait(100);
+ check('正文划选显示摘录与记笔记操作',
+ !!selectionText && await js(second.win, `!document.getElementById('selBar').classList.contains('hidden')
+ && !!document.querySelector('[data-sel="excerpt"]')
+ && !!document.querySelector('[data-sel="note"]')`));
+ await js(second.win, `document.querySelector('[data-sel="excerpt"]').click()`);
+ await wait(500);
+ check('摘录保留正文引用和精确位置',
+ readerStore.getState(entry.id).notes.some((note) => note.source === 'selection'
+ && note.quote.includes(selectionText) && note.locator && note.locator.page === 1));
+
+ await tool(second.win, 'pen');
+ const touchBase = 4;
+ await fireTouch(second.win, 'touchstart', [{ id: 1, x: 120, y: 380 }]);
+ await fireTouch(second.win, 'touchmove', [{ id: 1, x: 210, y: 410 }]);
+ await fireTouch(second.win, 'touchend', [], [{ id: 1, x: 210, y: 410 }]);
+ await wait(500);
+ check('单指触摸仍可完成画笔批注',
+ (await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1} 项`));
+
+ const beforePinchWidth = await js(second.win,
+ "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
+ await fireTouch(second.win, 'touchstart', [{ id: 11, x: 150, y: 460 }]);
+ await fireTouch(second.win, 'touchmove', [{ id: 11, x: 210, y: 480 }]);
+ await fireTouch(second.win, 'touchstart', [
+ { id: 11, x: 210, y: 480 },
+ { id: 12, x: 310, y: 480 }
+ ], [{ id: 12, x: 310, y: 480 }]);
+ await fireTouch(second.win, 'touchmove', [
+ { id: 11, x: 190, y: 480 },
+ { id: 12, x: 330, y: 480 }
+ ]);
+ check('双指缩放提供即时预览',
+ await js(second.win, "document.querySelector('.host-pdf').classList.contains('pinch-preview')"));
+ await fireTouch(second.win, 'touchend', [
+ { id: 11, x: 190, y: 480 }
+ ], [{ id: 12, x: 330, y: 480 }]);
+ await fireTouch(second.win, 'touchend', [], [{ id: 11, x: 190, y: 480 }]);
+ await wait(2600);
+ const afterPinchWidth = await js(second.win,
+ "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
+ check('PDF 双指缩放提交新比例并保持焦点页',
+ afterPinchWidth > beforePinchWidth
+ && (await js(second.win, "document.getElementById('posLabel').textContent")) === '第 1 页',
+ `${beforePinchWidth} -> ${afterPinchWidth}`);
+ check('第二指介入回滚未完成笔画',
+ (await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1} 项`));
+ check('双指缩放后可见页面不会变成黑色画布', await js(second.win, `(() => {
+ const visible = Array.from(document.querySelectorAll('.pdfx-page')).filter((page) => {
+ const rect = page.getBoundingClientRect();
+ return rect.bottom > 0 && rect.top < innerHeight;
+ });
+ return visible.length > 0 && visible.every((page) => {
+ const canvas = page.querySelector('.pdfx-canvas');
+ if (!canvas || canvas.width < 2 || canvas.height < 2) return false;
+ const pixel = canvas.getContext('2d').getImageData(
+ Math.floor(canvas.width / 2),
+ Math.floor(canvas.height / 2),
+ 1,
+ 1
+ ).data;
+ return pixel[0] + pixel[1] + pixel[2] > 90;
+ });
+ })()`));
+ await tool(second.win, 'text');
+ await fireTouch(second.win, 'touchstart', [{ id: 21, x: 420, y: 390 }]);
+ await fireTouch(second.win, 'touchstart', [
+ { id: 21, x: 420, y: 390 },
+ { id: 22, x: 520, y: 390 }
+ ], [{ id: 22, x: 520, y: 390 }]);
+ await fireTouch(second.win, 'touchend', [], [
+ { id: 21, x: 420, y: 390 },
+ { id: 22, x: 520, y: 390 }
+ ]);
+ await wait(1800);
+ check('文本工具下第二指介入不会误留文字批注',
+ (await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1} 项`));
+ check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
+ check('重开窗口无渲染错误', second.errors.length === 0, second.errors.slice(0, 2).join(' | '));
+
+ const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
+ fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
+
+ console.log('\n========== PDF 批注集成验证 ==========');
+ for (const [status, name, detail] of results) {
+ console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
+ }
+ const failed = results.filter((result) => result[0] === 'FAIL').length;
+ console.log(`\n通过 ${results.length - failed}/${results.length}`);
+ app.exit(failed ? 1 : 0);
+}).catch((error) => {
+ console.error('异常:', error);
+ app.exit(1);
+});
diff --git a/src/_test/electron/cover.integration.js b/src/_test/electron/cover.integration.js
new file mode 100644
index 0000000..68ca200
--- /dev/null
+++ b/src/_test/electron/cover.integration.js
@@ -0,0 +1,287 @@
+const { app, BrowserWindow, nativeImage } = require('electron');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const JSZip = require('jszip');
+
+const ROOT = path.resolve(__dirname, '..', '..', '..');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-cover-ui-'));
+app.setPath('userData', TMP);
+app.setPath('appData', TMP);
+
+const results = [];
+function check(name, condition, detail = '') {
+ results.push([condition ? 'OK' : 'FAIL', name, detail]);
+}
+
+function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function makePdf(file) {
+ const stream = 'q\n0.12 0.35 0.78 rg\n0 0 400 600 re f\nQ\nBT\n/F1 34 Tf\n1 1 1 rg\n74 300 Td\n(PDF COVER) Tj\nET\n';
+ const objects = [
+ '<< /Type /Catalog /Pages 2 0 R >>',
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
+ `<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}endstream`,
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
+ ];
+ let pdf = '%PDF-1.4\n';
+ const offsets = [0];
+ objects.forEach((object, index) => {
+ offsets.push(Buffer.byteLength(pdf));
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
+ });
+ const xref = Buffer.byteLength(pdf);
+ pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
+ for (let index = 1; index <= objects.length; index++) {
+ pdf += `${String(offsets[index]).padStart(10, '0')} 00000 n \n`;
+ }
+ pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+ fs.writeFileSync(file, pdf);
+}
+
+async function makeEpub(file) {
+ const zip = new JSZip();
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
+ zip.file('META-INF/container.xml', `
+
+
+ `);
+ zip.file('OEBPS/content.opf', `
+
+ EPUB Cover Fixture
+
+
+
+
+
+ `);
+ zip.file('OEBPS/cover.svg', `
+
+
+ `);
+ zip.file('OEBPS/chapter.xhtml', '
Fixture');
+ fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
+}
+
+async function makeFirstPageEpub(file) {
+ const zip = new JSZip();
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
+ zip.file('META-INF/container.xml', `
+
+
+ `);
+ zip.file('OPS/book.opf', `
+
+ First Page Fixture
+
+
+
+
+
+ `);
+ zip.file('OPS/title.xhtml', `
+
+ `);
+ zip.file('OPS/art.svg', `
+
+ `);
+ fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
+}
+
+async function makeTextEpub(file) {
+ const zip = new JSZip();
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
+ zip.file('META-INF/container.xml', `
+
+ `);
+ zip.file('META-INF/encryption.xml', `
+
+
+
+
+ `);
+ zip.file('book.opf', `
+ Text Only Fixture
+
+
+
+
+
+ `);
+ zip.file('chapter.xhtml', 'Text only');
+ zip.file('fonts/obfuscated.otf', Buffer.from('fixture'));
+ fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
+}
+
+async function waitForCover(entryId, library) {
+ const deadline = Date.now() + 35000;
+ while (Date.now() < deadline) {
+ const entry = library.get(entryId);
+ if (entry && entry.cover && !/^https?:/i.test(entry.cover) && fs.existsSync(entry.cover)) return entry;
+ await wait(150);
+ }
+ return library.get(entryId);
+}
+
+function sampleCover(file) {
+ const image = nativeImage.createFromPath(file);
+ const size = image.getSize();
+ const pixel = Array.from(image.crop({
+ x: Math.floor(size.width / 2),
+ y: Math.floor(size.height / 2),
+ width: 1,
+ height: 1
+ }).toBitmap());
+ return { width: size.width, height: size.height, pixel };
+}
+
+app.whenReady().then(async () => {
+ const pdfPath = path.join(TMP, 'local.pdf');
+ const epubPath = path.join(TMP, 'local.epub');
+ const firstPageEpubPath = path.join(TMP, 'first-page.epub');
+ const textEpubPath = path.join(TMP, 'text-only.epub');
+ makePdf(pdfPath);
+ await makeEpub(epubPath);
+ await makeFirstPageEpub(firstPageEpubPath);
+ await makeTextEpub(textEpubPath);
+
+ require(path.join(ROOT, 'main.js'));
+ const library = require(path.join(ROOT, 'src', 'library', 'store'));
+ const coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
+ library.init(path.join(TMP, 'library'));
+ await wait(600);
+
+ const win = BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'PeopleLib');
+ if (!win) throw new Error('主窗口未创建');
+ win.hide();
+ await win.webContents.executeJavaScript(
+ '(()=>{window.__coverChangeCount=0;window.api.library.onChanged(()=>window.__coverChangeCount++);return true})()'
+ );
+
+ const addResult = await win.webContents.executeJavaScript(`window.api.library.add({
+ title: 'Local PDF',
+ authors: ['Fixture'],
+ files: [{ path: ${JSON.stringify(pdfPath)}, name: 'local.pdf', format: 'PDF' }]
+ })`);
+ const pdfEntry = await waitForCover(addResult.data.id, library);
+ check('本地 PDF 自动生成封面', !!pdfEntry.cover && fs.existsSync(pdfEntry.cover), pdfEntry.cover);
+ const pdfSample = sampleCover(pdfEntry.cover);
+ check('PDF 封面来自第一页', pdfSample.pixel[0] > pdfSample.pixel[2] * 1.5, pdfSample.pixel.join(','));
+ check('PDF 缩略图尺寸受限', pdfSample.width <= 320 && pdfSample.height <= 440,
+ `${pdfSample.width}x${pdfSample.height}`);
+ check('异步生成完成后通知主界面刷新',
+ (await win.webContents.executeJavaScript('window.__coverChangeCount')) > 0);
+
+ const epubResult = await win.webContents.executeJavaScript(`window.api.library.add({
+ title: 'Local EPUB',
+ authors: [],
+ files: [{ path: ${JSON.stringify(epubPath)}, name: 'local.epub', format: 'EPUB' }]
+ })`);
+ const epubEntry = await waitForCover(epubResult.data.id, library);
+ check('本地 EPUB 自动生成封面', !!epubEntry.cover && fs.existsSync(epubEntry.cover), epubEntry.cover);
+ const epubSample = sampleCover(epubEntry.cover);
+ check('EPUB 优先使用内嵌封面', epubSample.pixel[2] > epubSample.pixel[0] * 1.5, epubSample.pixel.join(','));
+
+ const firstPageResult = await win.webContents.executeJavaScript(`window.api.library.add({
+ title: 'First Page EPUB',
+ authors: [],
+ files: [{ path: ${JSON.stringify(firstPageEpubPath)}, name: 'first-page.epub', format: 'EPUB' }]
+ })`);
+ const firstPageEntry = await waitForCover(firstPageResult.data.id, library);
+ const firstPageSample = sampleCover(firstPageEntry.cover);
+ check('EPUB 无封面元数据时使用首页图片',
+ firstPageSample.pixel[1] > firstPageSample.pixel[0] * 1.5, firstPageSample.pixel.join(','));
+
+ const textResult = await win.webContents.executeJavaScript(`window.api.library.add({
+ title: 'Text Only EPUB',
+ authors: ['Fixture'],
+ files: [{ path: ${JSON.stringify(textEpubPath)}, name: 'text-only.epub', format: 'EPUB' }]
+ })`);
+ const textEntry = await waitForCover(textResult.data.id, library);
+ check('含字体混淆的纯文本 EPUB 生成标题封面', !!textEntry.cover && fs.existsSync(textEntry.cover));
+
+ const sourceCover = 'data:image/png;base64,iVBORw0KGgo=';
+ const sourceResult = await win.webContents.executeJavaScript(`window.api.library.add({
+ title: 'Source Cover Priority',
+ cover: ${JSON.stringify(sourceCover)},
+ files: [{ path: ${JSON.stringify(pdfPath)}, name: 'local.pdf', format: 'PDF' }]
+ })`);
+ await wait(800);
+ check('已有来源封面不被生成封面替换', library.get(sourceResult.data.id).cover === sourceCover);
+
+ const badPdfPath = path.join(TMP, 'broken.pdf');
+ fs.writeFileSync(badPdfPath, 'not a pdf');
+ const badResult = await win.webContents.executeJavaScript(`window.api.library.add({
+ title: 'Broken PDF',
+ files: [{ path: ${JSON.stringify(badPdfPath)}, name: 'broken.pdf', format: 'PDF' }]
+ })`);
+ await coverGenerator.ensure(badResult.data.id).catch(() => {});
+ check('损坏 PDF 不阻断入库且不写入假封面', badResult.ok && !library.get(badResult.data.id).cover);
+
+ const changed = library.add({
+ title: 'Changing file',
+ files: [{ path: pdfPath, name: 'local.pdf', format: 'PDF' }]
+ });
+ const changingJob = coverGenerator.ensure(changed.id);
+ library.update(changed.id, {
+ title: 'Changed to EPUB',
+ files: [{ path: epubPath, name: 'local.epub', format: 'EPUB' }]
+ });
+ await changingJob;
+ const changedEntry = library.get(changed.id);
+ const changedSample = sampleCover(changedEntry.cover);
+ check('提取期间文件变更会丢弃旧结果并重新生成',
+ changedSample.pixel[2] > changedSample.pixel[0] * 1.5, changedSample.pixel.join(','));
+
+ const scanPdf = path.join(library.filesDir(), 'scanned.pdf');
+ fs.copyFileSync(pdfPath, scanPdf);
+ const scanResult = await win.webContents.executeJavaScript('window.api.library.scan()');
+ const scanned = library.list().find((entry) => entry.files.some((file) => file.path === scanPdf));
+ const scannedEntry = scanned && await waitForCover(scanned.id, library);
+ check('目录扫描条目自动生成封面', scanResult.data.added === 1
+ && !!scannedEntry && fs.existsSync(scannedEntry.cover || ''));
+
+ const server = http.createServer((_request, response) => {
+ response.writeHead(200, {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': 'attachment; filename="download.pdf"'
+ });
+ response.end(fs.readFileSync(pdfPath));
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const downloadResult = await win.webContents.executeJavaScript(`window.api.downloadFile(
+ ${JSON.stringify(`http://127.0.0.1:${server.address().port}/download.pdf`)},
+ 'download.pdf',
+ undefined,
+ undefined,
+ { title: 'Downloaded PDF', authors: [], cover: '', sourceId: 'fixture', sourcePostId: '1' }
+ )`);
+ const downloadedEntry = downloadResult.ok && await waitForCover(downloadResult.data.entryId, library);
+ check('来源下载并挂载后自动生成封面', !!downloadedEntry && fs.existsSync(downloadedEntry.cover || ''));
+ await new Promise((resolve) => server.close(resolve));
+
+ check('所有生成封面均为 JPEG',
+ [pdfEntry, epubEntry, firstPageEntry, textEntry, changedEntry, scannedEntry, downloadedEntry].every((entry) => {
+ if (!entry || !entry.cover) return false;
+ const bytes = fs.readFileSync(entry.cover);
+ return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
+ }));
+
+ console.log('\n========== 自动封面集成验证 ==========');
+ for (const [status, name, detail] of results) {
+ console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
+ }
+ const failed = results.filter((result) => result[0] === 'FAIL').length;
+ console.log(`\n通过 ${results.length - failed}/${results.length}`);
+ coverGenerator.close();
+ win.destroy();
+ app.exit(failed ? 1 : 0);
+}).catch((error) => {
+ console.error('异常:', error);
+ app.exit(1);
+});
diff --git a/src/_test/electron/download.integration.js b/src/_test/electron/download.integration.js
new file mode 100644
index 0000000..63541f4
--- /dev/null
+++ b/src/_test/electron/download.integration.js
@@ -0,0 +1,345 @@
+// Validate the real main-process download stream, preload progress bridge, library
+// attachment, and the completed-download button styling without external network.
+const { app, BrowserWindow, safeStorage } = require('electron');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..', '..');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-download-ui-'));
+const LIBRARY_DIR = path.join(TMP, 'library');
+app.setPath('userData', TMP);
+// main.js derives its development userData directory from appData. Redirect both
+// before requiring it so even its initial module setup cannot touch the real profile.
+app.setPath('appData', TMP);
+
+const knownChunks = Array.from({ length: 6 }, (_unused, index) => Buffer.from(
+ `known-chunk-${index}-` + String.fromCharCode(65 + index).repeat(24 * 1024)
+));
+const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
+ `unknown-chunk-${index}-` + String.fromCharCode(97 + index).repeat(12 * 1024)
+));
+const knownPayload = Buffer.concat(knownChunks);
+const unknownPayload = Buffer.concat(unknownChunks);
+
+const results = [];
+let server;
+let testWindow;
+
+function check(name, condition, detail = '') {
+ results.push([condition ? 'OK' : 'FAIL', name, detail]);
+}
+
+function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function serveChunks(response, chunks, contentLength) {
+ const headers = {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'Content-Disposition': 'attachment; filename="fixture.txt"',
+ Connection: 'close'
+ };
+ if (contentLength != null) headers['Content-Length'] = String(contentLength);
+ response.writeHead(200, headers);
+ if (response.socket) response.socket.setNoDelay(true);
+
+ let index = 0;
+ const sendNext = () => {
+ if (index >= chunks.length) {
+ response.end();
+ return;
+ }
+ response.write(chunks[index]);
+ index += 1;
+ setTimeout(sendNext, 130);
+ };
+ sendNext();
+}
+
+function monotonic(events) {
+ return events.every((event, index) => {
+ const current = Number(event.receivedBytes);
+ const previous = index ? Number(events[index - 1].receivedBytes) : 0;
+ return Number.isFinite(current) && current >= 0 && current >= previous;
+ });
+}
+
+function isWithin(base, target) {
+ const relative = path.relative(path.resolve(base), path.resolve(target));
+ return relative === '' || (!relative.startsWith(`..${path.sep}`)
+ && relative !== '..' && !path.isAbsolute(relative));
+}
+
+function cssRule(css, selector) {
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
+ return match ? match[1] : '';
+}
+
+function declaration(rule, property) {
+ const match = rule.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i'));
+ return match ? match[1].trim() : '';
+}
+
+function parseCssColor(value, css) {
+ let color = String(value || '').trim();
+ const variable = color.match(/^var\((--[\w-]+)\)$/);
+ if (variable) {
+ const match = css.match(new RegExp(`${variable[1]}\\s*:\\s*([^;]+)`, 'i'));
+ color = match ? match[1].trim() : '';
+ }
+ const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
+ if (!hex) return null;
+ const digits = hex[1].length === 3
+ ? hex[1].split('').map((digit) => digit + digit).join('')
+ : hex[1];
+ return [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16));
+}
+
+function luminance(rgb) {
+ if (!rgb) return NaN;
+ const channels = rgb.map((value) => {
+ const channel = value / 255;
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
+}
+
+function contrast(a, b) {
+ const first = luminance(a);
+ const second = luminance(b);
+ return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
+}
+
+async function downloadInRenderer(url, name, meta, slot) {
+ return testWindow.webContents.executeJavaScript(`(() => {
+ window[${JSON.stringify(slot)}] = [];
+ return window.api.downloadFile(
+ ${JSON.stringify(url)},
+ ${JSON.stringify(name)},
+ undefined,
+ undefined,
+ ${JSON.stringify(meta)},
+ (event) => window[${JSON.stringify(slot)}].push({ ...event })
+ ).then((result) => ({ result, events: window[${JSON.stringify(slot)}] }));
+ })()`);
+}
+
+async function closeServer() {
+ if (!server || !server.listening) return;
+ await new Promise((resolve) => server.close(resolve));
+}
+
+async function run() {
+ try {
+ server = http.createServer((request, response) => {
+ if (request.url === '/known.txt') {
+ serveChunks(response, knownChunks, knownPayload.length);
+ } else if (request.url === '/unknown.txt') {
+ serveChunks(response, unknownChunks, null);
+ } else {
+ response.writeHead(404, { Connection: 'close' });
+ response.end('not found');
+ }
+ });
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+
+ require(path.join(ROOT, 'main.js'));
+
+ // main.js initializes these modules as a side effect. Reinitialize every
+ // profile-backed store against this harness's isolated temporary directory.
+ const settings = require(path.join(ROOT, 'src', 'settings'));
+ const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
+ const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
+ const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
+ const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
+ const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
+ const library = require(path.join(ROOT, 'src', 'library', 'store'));
+ settings.init(TMP);
+ readerStore.init(TMP);
+ annotations.init(TMP);
+ aiConfig.init(TMP, safeStorage);
+ zlibAuth.init(TMP, safeStorage);
+ semanticKey.init(TMP, safeStorage);
+ library.init(LIBRARY_DIR);
+ require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
+
+ const htmlPath = path.join(TMP, 'download-test.html');
+ fs.writeFileSync(htmlPath, `
+
+
+ Download integration
+ ready `);
+
+ const rendererErrors = [];
+ testWindow = new BrowserWindow({
+ show: false,
+ width: 640,
+ height: 480,
+ webPreferences: {
+ preload: path.join(ROOT, 'preload.js'),
+ contextIsolation: true,
+ nodeIntegration: false
+ }
+ });
+ testWindow.webContents.on('console-message', (event) => {
+ const { level, message } = event;
+ if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
+ rendererErrors.push(message);
+ }
+ });
+ testWindow.webContents.on('preload-error', (_event, _preloadPath, error) => {
+ rendererErrors.push(`preload: ${error.message}`);
+ });
+ testWindow.webContents.on('render-process-gone', (_event, details) => {
+ rendererErrors.push(`renderer gone: ${details.reason}`);
+ });
+ testWindow.webContents.on('did-fail-load', (_event, code, description, validatedURL, isMainFrame) => {
+ if (isMainFrame) rendererErrors.push(`load ${code}: ${description} (${validatedURL})`);
+ });
+ await testWindow.loadFile(htmlPath);
+ await testWindow.webContents.executeJavaScript(`(() => {
+ window.__pageErrors = [];
+ addEventListener('error', (event) => window.__pageErrors.push(String(event.message || event.error)));
+ addEventListener('unhandledrejection', (event) => window.__pageErrors.push(String(event.reason)));
+ })()`);
+
+ check('preload 暴露下载 API',
+ await testWindow.webContents.executeJavaScript('typeof window.api.downloadFile === "function"'));
+
+ const port = server.address().port;
+ const known = await downloadInRenderer(
+ `http://127.0.0.1:${port}/known.txt`,
+ 'known-fixture.txt',
+ {
+ title: 'Known Length Download',
+ authors: ['Integration Fixture'],
+ sourceId: 'download-test',
+ sourcePostId: 'known'
+ },
+ '__knownProgress'
+ );
+ const knownEvents = known.events || [];
+ const knownResult = known.result;
+ const knownFinal = knownEvents[knownEvents.length - 1] || {};
+ check('Content-Length 下载成功', !!(knownResult && knownResult.ok),
+ knownResult && knownResult.error);
+ check('Content-Length 下载产生多个进度事件',
+ knownEvents.length >= 4 && new Set(knownEvents.map((event) => event.receivedBytes)).size >= 3,
+ `事件=${knownEvents.length}`);
+ check('Content-Length 进度单调递增', monotonic(knownEvents),
+ knownEvents.map((event) => event.receivedBytes).join(','));
+ check('Content-Length 进度总量正确',
+ knownEvents.length > 0 && knownEvents.every((event) => event.totalBytes === knownPayload.length),
+ `期望=${knownPayload.length}`);
+ check('Content-Length 最终进度完整',
+ knownFinal.complete === true && knownFinal.percent === 1
+ && knownFinal.receivedBytes === knownPayload.length,
+ JSON.stringify(knownFinal));
+
+ const knownPath = knownResult && knownResult.ok && knownResult.data.path;
+ check('Content-Length 下载字节完全一致',
+ !!knownPath && fs.existsSync(knownPath) && fs.readFileSync(knownPath).equals(knownPayload),
+ knownPath || '');
+ const knownEntry = knownResult && knownResult.ok
+ ? library.get(knownResult.data.entryId) : null;
+ check('Content-Length 下载挂载到书库条目',
+ !!knownEntry && knownEntry.title === 'Known Length Download'
+ && knownEntry.files.some((file) => file.path === knownPath && file.exists),
+ knownEntry && knownEntry.id);
+ check('下载文件仅写入隔离书库', !!knownPath && isWithin(LIBRARY_DIR, knownPath), knownPath || '');
+
+ const unknown = await downloadInRenderer(
+ `http://127.0.0.1:${port}/unknown.txt`,
+ 'unknown-fixture.txt',
+ {
+ title: 'Unknown Length Download',
+ authors: [],
+ sourceId: 'download-test',
+ sourcePostId: 'unknown'
+ },
+ '__unknownProgress'
+ );
+ const unknownEvents = unknown.events || [];
+ const unknownResult = unknown.result;
+ const unknownFinal = unknownEvents[unknownEvents.length - 1] || {};
+ check('无 Content-Length 下载成功', !!(unknownResult && unknownResult.ok),
+ unknownResult && unknownResult.error);
+ check('无 Content-Length 下载产生多个单调进度事件',
+ unknownEvents.length >= 4 && monotonic(unknownEvents), `事件=${unknownEvents.length}`);
+ check('无 Content-Length 使用不确定进度',
+ unknownEvents.some((event) => !event.complete && event.receivedBytes > 0
+ && event.totalBytes === null && event.percent === null),
+ JSON.stringify(unknownEvents.slice(0, 3)));
+ check('无 Content-Length 最终进度完整',
+ unknownFinal.complete === true && unknownFinal.percent === 1
+ && unknownFinal.totalBytes === null
+ && unknownFinal.receivedBytes === unknownPayload.length,
+ JSON.stringify(unknownFinal));
+
+ const unknownPath = unknownResult && unknownResult.ok && unknownResult.data.path;
+ check('无 Content-Length 下载字节完全一致',
+ !!unknownPath && fs.existsSync(unknownPath)
+ && fs.readFileSync(unknownPath).equals(unknownPayload),
+ unknownPath || '');
+ const unknownEntry = unknownResult && unknownResult.ok
+ ? library.get(unknownResult.data.entryId) : null;
+ check('无 Content-Length 下载挂载到书库条目',
+ !!unknownEntry && unknownEntry.title === 'Unknown Length Download'
+ && unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
+ unknownEntry && unknownEntry.id);
+
+ const css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'style.css'), 'utf8');
+ const downloadedRule = cssRule(css, '.dl-btn.downloaded');
+ const backgroundValue = declaration(downloadedRule, 'background');
+ const foregroundValue = declaration(downloadedRule, 'color');
+ const background = parseCssColor(backgroundValue, css);
+ const foreground = parseCssColor(foregroundValue, css);
+ check('下载完成按钮存在静态样式规则', !!downloadedRule, downloadedRule);
+ check('下载完成按钮使用非蓝绿色背景',
+ !!background && background[1] > background[0] + 20
+ && background[1] > background[2] + 20
+ && !/accent|blue/i.test(backgroundValue),
+ `${backgroundValue} -> ${background || '无法解析'}`);
+ check('下载完成按钮使用高对比暗色前景',
+ !!foreground && Math.max(...foreground) < 64 && contrast(background, foreground) >= 4.5,
+ `${foregroundValue}; 对比度=${contrast(background, foreground).toFixed(2)}`);
+
+ await wait(100);
+ const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
+ check('下载流程没有渲染器错误',
+ rendererErrors.length === 0 && pageErrors.length === 0,
+ rendererErrors.concat(pageErrors).join(' | '));
+ } catch (error) {
+ check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
+ } finally {
+ try {
+ const coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
+ coverGenerator.close();
+ } catch (error) { /* main.js may not have loaded */ }
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.destroy();
+ }
+ await closeServer().catch((error) => {
+ check('本地 HTTP 服务器正常关闭', false, error.message);
+ });
+
+ console.log('\n========== 下载进度集成验证 ==========');
+ for (const [status, name, detail] of results) {
+ console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
+ }
+ const failed = results.filter((result) => result[0] === 'FAIL').length;
+ console.log(`\n通过 ${results.length - failed}/${results.length}`);
+ app.exit(failed ? 1 : 0);
+ }
+}
+
+app.whenReady().then(run).catch((error) => {
+ console.error('异常:', error);
+ app.exit(1);
+});
diff --git a/src/_test/electron/library-notes.integration.js b/src/_test/electron/library-notes.integration.js
new file mode 100644
index 0000000..c4b3157
--- /dev/null
+++ b/src/_test/electron/library-notes.integration.js
@@ -0,0 +1,1467 @@
+const { app, BrowserWindow, safeStorage, dialog } = require('electron');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..', '..');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-library-notes-ui-'));
+const LIBRARY_DIR = path.join(TMP, 'library');
+const FIXTURE_FILE = path.join(TMP, 'retained-book.txt');
+const FIXTURE_PDF = path.join(TMP, 'retained-book.pdf');
+
+function makePdf(file) {
+ const objects = [
+ '',
+ '<< /Type /Catalog /Pages 2 0 R >>',
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
+ ];
+ const stream = 'BT /F1 16 Tf 40 320 Td (Retained Research Book) Tj ET';
+ objects.push(`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`);
+ let body = '%PDF-1.4\n';
+ const offsets = [0];
+ for (let i = 1; i < objects.length; i++) {
+ offsets[i] = Buffer.byteLength(body);
+ body += `${i} 0 obj\n${objects[i]}\nendobj\n`;
+ }
+ const xref = Buffer.byteLength(body);
+ body += `xref\n0 ${objects.length}\n0000000000 65535 f \n`;
+ for (let i = 1; i < objects.length; i++) {
+ body += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
+ }
+ body += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+ fs.writeFileSync(file, body);
+}
+
+// main.js derives its development profile from appData rather than userData.
+// Redirect both before requiring it so even its initial side effects stay isolated.
+app.setPath('appData', TMP);
+app.setPath('userData', TMP);
+fs.writeFileSync(FIXTURE_FILE, 'PeopleLib library and notes integration fixture.\n');
+makePdf(FIXTURE_PDF);
+
+const results = [];
+const rendererErrors = [];
+let win = null;
+let coverGenerator = null;
+
+function check(name, condition, detail = '') {
+ results.push([condition ? 'OK' : 'FAIL', name, detail]);
+}
+
+function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function js(source) {
+ if (!win || win.isDestroyed()) throw new Error('主窗口不可用');
+ return win.webContents.executeJavaScript(source);
+}
+
+async function poll(name, predicate, timeout = 8000) {
+ const deadline = Date.now() + timeout;
+ let lastError = null;
+ while (Date.now() < deadline) {
+ try {
+ if (await predicate()) return;
+ } catch (error) {
+ lastError = error;
+ }
+ await wait(50);
+ }
+ const detail = lastError ? lastError.message : '等待超时';
+ check(name, false, detail);
+ throw new Error(`${name}: ${detail}`);
+}
+
+async function pollJs(name, source, timeout) {
+ return poll(name, () => js(source), timeout);
+}
+
+async function waitForModal(title) {
+ await pollJs(
+ `弹窗显示:${title}`,
+ `(() => {
+ const modal = document.getElementById('modal');
+ return modal && !modal.classList.contains('hidden')
+ && document.getElementById('modalTitle').textContent === ${JSON.stringify(title)};
+ })()`
+ );
+}
+
+async function submitModal() {
+ await js("document.getElementById('modalOk').click()");
+ await pollJs(
+ '弹窗提交完成',
+ "document.getElementById('modal').classList.contains('hidden')"
+ );
+}
+
+async function libraryTitles() {
+ return js(`Array.from(document.querySelectorAll('#libGrid .card .card-title'))
+ .map((element) => element.textContent.trim())`);
+}
+
+async function noteCards() {
+ return js(`Array.from(document.querySelectorAll('#notesList .note-card')).map((card) => ({
+ book: (card.querySelector('.note-book-title') || {}).textContent || '',
+ title: (card.querySelector('.note-title') || {}).textContent || '',
+ text: (card.querySelector('.note-text') || {}).textContent || '',
+ quote: (card.querySelector('.note-quote') || {}).textContent || '',
+ source: (card.querySelector('.note-badge.source') || {}).textContent || '',
+ type: (card.querySelector('.note-badge.type') || {}).textContent || '',
+ pinned: card.classList.contains('pinned'),
+ tags: Array.from(card.querySelectorAll('.note-tags .note-tag')).map((tag) => tag.textContent),
+ meta: (card.querySelector('.note-meta') || {}).textContent || ''
+ }))`);
+}
+
+function finish() {
+ console.log('\n========== 书库整理与我的笔记集成验证 ==========');
+ for (const [status, name, detail] of results) {
+ console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
+ }
+ const failed = results.filter((result) => result[0] === 'FAIL').length;
+ console.log(`\n通过 ${results.length - failed}/${results.length}`);
+
+ try {
+ if (coverGenerator) coverGenerator.close();
+ } catch (error) {
+ console.error('关闭封面任务失败:', error.message);
+ }
+ for (const window of BrowserWindow.getAllWindows()) {
+ try {
+ if (!window.isDestroyed()) window.destroy();
+ } catch (error) {
+ console.error('关闭窗口失败:', error.message);
+ }
+ }
+ app.exit(failed ? 1 : 0);
+}
+
+app.on('browser-window-created', (_event, window) => {
+ window.webContents.on('console-message', (consoleEvent) => {
+ const { level, message, lineNumber, sourceId } = consoleEvent;
+ if (level < 2 || /Autofill|Indexing all PDF objects/i.test(message)) return;
+ const detail = `${message}${sourceId ? ` (${sourceId}:${lineNumber || 0})` : ''}`;
+ rendererErrors.push(detail);
+ console.error('RENDERER:', detail);
+ });
+});
+
+app.whenReady().then(async () => {
+ require(path.join(ROOT, 'main.js'));
+
+ // main.js initializes singleton stores as a require-time side effect. Point
+ // every profile-backed singleton back at this test's isolated directory.
+ const settings = require(path.join(ROOT, 'src', 'settings'));
+ const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
+ const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
+ const noteAssets = require(path.join(ROOT, 'src', 'reader', 'note-assets'));
+ const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
+ const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
+ const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
+ const library = require(path.join(ROOT, 'src', 'library', 'store'));
+ coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
+
+ settings.init(TMP);
+ readerStore.init(TMP);
+ annotations.init(TMP);
+ noteAssets.init(TMP);
+ aiConfig.init(TMP, safeStorage);
+ zlibAuth.init(TMP, safeStorage);
+ semanticKey.init(TMP, safeStorage);
+ library.init(LIBRARY_DIR);
+ require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
+
+ check(
+ '所有配置和书库数据使用隔离目录',
+ app.getPath('appData') === TMP
+ && app.getPath('userData').startsWith(TMP)
+ && library.getRoot() === LIBRARY_DIR,
+ TMP
+ );
+
+ const researchShelf = library.addShelf({ name: '研究书架' });
+ const reviewShelf = library.addShelf({ name: '待复核书架' });
+ const retainedBook = library.add({
+ title: 'Retained Research Book',
+ authors: ['Alice Author'],
+ shelfId: researchShelf.id,
+ tags: ['Methods', 'Shared'],
+ files: [
+ { path: FIXTURE_FILE, name: 'retained-book.txt', format: 'TXT' },
+ { path: FIXTURE_PDF, name: 'retained-book.pdf', format: 'PDF' }
+ ]
+ });
+ const selectionBook = library.add({
+ title: 'Selection Source Book',
+ authors: ['Bob Author'],
+ shelfId: reviewShelf.id,
+ tags: ['Shared', 'Review'],
+ files: []
+ });
+ const purgeBook = library.add({
+ title: 'Purge Reading Data Book',
+ authors: ['Carol Author'],
+ shelfId: null,
+ tags: ['Archive'],
+ files: []
+ });
+
+ const notebook = readerStore.addCollection({ name: '研究笔记本' });
+ readerStore.setBookSnapshot(retainedBook.id, {
+ title: retainedBook.title,
+ authors: retainedBook.authors
+ });
+ const manualNote = readerStore.addNote(retainedBook.id, {
+ title: 'Pinned Research Note',
+ text: 'Alpha insight searchable body',
+ quote: '',
+ context: 'Manual note context',
+ source: 'manual',
+ locator: null,
+ documentKey: null,
+ fileIndex: null,
+ collectionId: notebook.id,
+ tags: ['focus', 'shared-note'],
+ pinned: true
+ });
+ readerStore.setBookSnapshot(selectionBook.id, {
+ title: selectionBook.title,
+ authors: selectionBook.authors
+ });
+ const selectionNote = readerStore.addNote(selectionBook.id, {
+ title: 'Selected Passage',
+ text: 'Selection commentary body',
+ quote: 'Quoted selection excerpt',
+ context: 'Around selection context',
+ source: 'selection',
+ locator: { type: 'pdf', page: 3 },
+ documentKey: 'fixture-document-key',
+ fileIndex: 0,
+ collectionId: null,
+ tags: ['excerpt'],
+ pinned: false
+ });
+ readerStore.setBookSnapshot(purgeBook.id, {
+ title: purgeBook.title,
+ authors: purgeBook.authors
+ });
+ const purgeNote = readerStore.addNote(purgeBook.id, {
+ title: 'Purge With Reading Data',
+ text: 'This note must disappear with explicit reading-data deletion',
+ source: 'manual',
+ locator: null,
+ collectionId: null,
+ tags: ['purge-note'],
+ pinned: false
+ });
+ const realNow = Date.now;
+ try {
+ Date.now = () => 1000;
+ readerStore.setProgress(retainedBook.id, { kind: 'pdf', page: 1 }, 0.1);
+ Date.now = () => 2000;
+ readerStore.setProgress(selectionBook.id, { kind: 'pdf', page: 2 }, 0.2);
+ } finally {
+ Date.now = realNow;
+ }
+
+ check(
+ '真实存储预置三本书、两个书架和三个结构化笔记',
+ library.list().length === 3
+ && library.listShelves().length === 2
+ && readerStore.listNotes({}).length === 3
+ && readerStore.listCollections().length === 1
+ );
+ check(
+ '预置笔记包含人工与摘录结构字段',
+ manualNote.source === 'manual'
+ && manualNote.collectionId === notebook.id
+ && manualNote.pinned === true
+ && selectionNote.source === 'selection'
+ && selectionNote.quote === 'Quoted selection excerpt'
+ && selectionNote.locator.page === 3
+ && purgeNote.source === 'manual'
+ );
+
+ await poll('主窗口创建', async () => {
+ win = BrowserWindow.getAllWindows()
+ .find((candidate) => candidate.getTitle() === 'PeopleLib') || null;
+ return !!win;
+ });
+ win.hide();
+ win.webContents.setBackgroundThrottling(false);
+ await pollJs(
+ '主窗口书库渲染完成',
+ "document.readyState === 'complete' && document.querySelectorAll('#libGrid .card').length === 3"
+ );
+
+ check(
+ '顶部提供“我的笔记”页签',
+ await js(`(() => {
+ const tab = document.querySelector('.tab[data-tab="notes"]');
+ return !!tab && tab.textContent.trim() === '我的笔记';
+ })()`)
+ );
+ check(
+ '设置旁边提供明暗主题图标按钮',
+ await js(`(() => {
+ const settingsButton = document.querySelector('.tab[data-tab="settings"]');
+ const themeButton = document.getElementById('uiThemeBtn');
+ return !!themeButton && settingsButton.previousElementSibling === themeButton
+ && !!themeButton.querySelector('.ui-theme-sun')
+ && themeButton.title === '切换到明亮主题'
+ && document.querySelector('.titlebar-left').textContent.includes('人民阅读器')
+ && document.querySelector('.brand-logo-dark').naturalWidth > 0;
+ })()`)
+ );
+ await js("document.getElementById('uiThemeBtn').click()");
+ await pollJs(
+ '主窗口切换为明亮主题',
+ `document.documentElement.dataset.uiTheme === 'light'
+ && document.getElementById('uiThemeBtn').title === '切换到暗色主题'
+ && getComputedStyle(document.body).color === 'rgb(31, 41, 55)'
+ && getComputedStyle(document.querySelector('.brand-logo-light')).display !== 'none'
+ && document.querySelector('.brand-logo-light').naturalWidth > 0`
+ );
+ check(
+ '主窗口主题与阅读器主题偏好同步持久化',
+ settings.get('ui.theme', 'dark') === 'light'
+ && settings.get('reader.uiTheme', 'dark') === 'light'
+ );
+ await js("document.querySelector('.tab[data-tab=\"settings\"]').click()");
+ await js("document.getElementById('zlibLoginBtn').click()");
+ await pollJs('Z-Library 登录弹窗打开', "!document.getElementById('modal').classList.contains('hidden')");
+ check(
+ 'Z-Library 登录取消与确定按钮尺寸一致',
+ await js(`(() => {
+ const cancel = document.getElementById('modalCancel').getBoundingClientRect();
+ const ok = document.getElementById('modalOk').getBoundingClientRect();
+ return cancel.width === ok.width && cancel.height === ok.height;
+ })()`)
+ );
+ await js("document.getElementById('modalCancel').click()");
+ await js("document.querySelector('.tab[data-tab=\"library\"]').click()");
+ await pollJs('返回书库页', "document.querySelectorAll('#libGrid .card').length === 3");
+ const allTitles = await libraryTitles();
+ check(
+ '全部书籍显示三张卡和总数',
+ allTitles.length === 3
+ && ['Purge Reading Data Book', 'Selection Source Book', 'Retained Research Book']
+ .every((title) => allTitles.includes(title))
+ && (await js("document.getElementById('libStatus').textContent")) === '共 3 条'
+ );
+ await js(`(() => {
+ document.getElementById('librarySearchInput').value = 'Rsrch';
+ document.getElementById('librarySearchBtn').click();
+ })()`);
+ await pollJs(
+ '书库标题模糊搜索刷新',
+ `document.querySelectorAll('#libGrid .card').length === 1
+ && document.querySelector('#libGrid .card-title').textContent === 'Retained Research Book'`
+ );
+ check(
+ '书库模糊匹配标题',
+ (await js("document.getElementById('libStatus').textContent"))
+ === '搜索“Rsrch”显示 1 条,当前分类 3 条'
+ );
+ await js(`(() => {
+ document.getElementById('librarySearchInput').value = 'bob auth';
+ document.getElementById('librarySearchBtn').click();
+ })()`);
+ await pollJs(
+ '书库作者模糊搜索刷新',
+ `document.querySelectorAll('#libGrid .card').length === 1
+ && document.querySelector('#libGrid .card-title').textContent === 'Selection Source Book'`
+ );
+ check('书库模糊匹配作者', JSON.stringify(await libraryTitles())
+ === JSON.stringify(['Selection Source Book']));
+ await js("document.getElementById('libraryClearSearchBtn').click()");
+ await pollJs('清除书库搜索恢复全部卡片', "document.querySelectorAll('#libGrid .card').length === 3");
+ check(
+ '设置中提供最近阅读排序',
+ await js(`Array.from(document.getElementById('sortSelect').options)
+ .some((option) => option.value === 'recent' && option.textContent === '最近阅读')`)
+ );
+ await js(`(() => {
+ const select = document.getElementById('sortSelect');
+ select.value = 'recent';
+ select.dispatchEvent(new Event('change'));
+ })()`);
+ await pollJs(
+ '最近阅读排序刷新',
+ `Array.from(document.querySelectorAll('#libGrid .card-title'))
+ .map((node) => node.textContent).join('|')
+ === 'Selection Source Book|Retained Research Book|Purge Reading Data Book'`
+ );
+ check(
+ '最近阅读按阅读进度时间降序且未读条目置后',
+ JSON.stringify(await libraryTitles()) === JSON.stringify([
+ 'Selection Source Book',
+ 'Retained Research Book',
+ 'Purge Reading Data Book'
+ ])
+ );
+ check(
+ '书架和标签过多时左侧栏独立滚动',
+ await js(`new Promise((resolve) => {
+ const list = document.getElementById('libraryShelfList');
+ for (let index = 0; index < 80; index++) {
+ const row = document.createElement('button');
+ row.className = 'library-filter';
+ row.textContent = \`滚动测试书架 \${index + 1}\`;
+ list.appendChild(row);
+ }
+ const sidebar = document.querySelector('.library-sidebar');
+ sidebar.scrollTop = sidebar.scrollHeight;
+ requestAnimationFrame(() => {
+ const last = list.lastElementChild;
+ resolve(
+ getComputedStyle(sidebar).overflowY === 'auto'
+ && sidebar.scrollHeight > sidebar.clientHeight
+ && last.getBoundingClientRect().bottom <= sidebar.getBoundingClientRect().bottom + 1
+ );
+ });
+ })`)
+ );
+ await js("Library.refresh(true)");
+ await js(`window.__pendingCoverCard = document.querySelector(
+ '#libGrid .card[data-id="${selectionBook.id}"]'
+ )`);
+ const stableTag = library.addTag({ name: '异步刷新占位标签' });
+ await pollJs(
+ '无关书库通知刷新侧栏',
+ `Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
+ .some((button) => button.textContent.includes('异步刷新占位标签'))`
+ );
+ check(
+ '其它封面或目录刷新不会重建未获取封面的卡片',
+ await js(`window.__pendingCoverCard === document.querySelector(
+ '#libGrid .card[data-id="${selectionBook.id}"]'
+ ) && window.__pendingCoverCard.querySelector('.card-cover')
+ .dataset.coverState === 'pending'`)
+ );
+ library.removeTag(stableTag.id);
+ await pollJs(
+ '移除异步刷新占位标签',
+ `!Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
+ .some((button) => button.textContent.includes('异步刷新占位标签'))`
+ );
+ check(
+ '书库卡片操作使用纯图标和悬浮文字',
+ await js(`Array.from(document.querySelectorAll('#libGrid .lib-card-actions button')).every(
+ (button) => !!button.querySelector('svg')
+ && !button.textContent.trim()
+ && !!button.title
+ && button.getAttribute('aria-label') === button.title
+ )`)
+ );
+ check(
+ '书库长标题单行省略并通过悬浮提示显示全文',
+ await js(`(() => {
+ const title = document.querySelector(
+ '#libGrid .card[data-id="${retainedBook.id}"] .card-title'
+ );
+ const style = getComputedStyle(title);
+ return title.title === title.textContent
+ && style.whiteSpace === 'nowrap'
+ && style.overflow === 'hidden'
+ && style.textOverflow === 'ellipsis';
+ })()`)
+ );
+ await js(`document.querySelector(
+ '#libGrid .card[data-id="${retainedBook.id}"] .card-cover'
+ ).click()`);
+ let readerWindow = null;
+ await poll('点击封面创建内置阅读器窗口', async () => {
+ readerWindow = BrowserWindow.getAllWindows().find((candidate) => (
+ candidate !== win && candidate.webContents.getURL().includes('/reader.html?')
+ )) || null;
+ return !!readerWindow;
+ });
+ await poll('封面打开的 PDF 在内置阅读器渲染', async () => (
+ !readerWindow.isDestroyed()
+ && readerWindow.webContents.executeJavaScript(
+ "document.querySelector('.pdfx-page[data-page=\"1\"] .pdfx-canvas')?.width > 0"
+ )
+ ), 15000);
+ check('点击可阅读图书封面直接打开内置阅读器', !!readerWindow);
+ readerWindow.destroy();
+ await wait(200);
+ check(
+ '标签侧栏显示真实聚合计数',
+ await js(`(() => {
+ const buttons = Array.from(document.querySelectorAll('#libraryTagList .library-filter'));
+ const shared = buttons.find((button) => button.textContent.includes('# Shared'));
+ const methods = buttons.find((button) => button.textContent.includes('# Methods'));
+ return !!shared && shared.querySelector('.library-filter-count').textContent === '2'
+ && !!methods && methods.querySelector('.library-filter-count').textContent === '1';
+ })()`)
+ );
+
+ await js("document.getElementById('addTagBtn').click()");
+ await waitForModal('新建标签');
+ check(
+ '新建标签使用应用内弹窗',
+ await js("!!document.getElementById('libraryTagName')")
+ );
+ await js("document.getElementById('libraryTagName').value = '界面标签'");
+ await submitModal();
+ await poll(
+ '新标签写入真实存储',
+ () => Promise.resolve(library.listTags().some((tag) => tag.name === '界面标签'))
+ );
+ let uiTag = library.listTags().find((tag) => tag.name === '界面标签');
+ check(
+ '新建零使用标签并自动选中',
+ !!uiTag && uiTag.count === 0
+ && await js(`(() => {
+ const button = Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
+ .find((candidate) => candidate.textContent.includes('# 界面标签'));
+ return !!button && button.classList.contains('active')
+ && document.getElementById('libStatus').textContent === '显示 0 条,共 3 条';
+ })()`)
+ );
+ await js(`document.querySelector('[aria-label="重命名界面标签"]').click()`);
+ await waitForModal('重命名标签');
+ await js("document.getElementById('libraryTagName').value = '界面标签已重命名'");
+ await submitModal();
+ await poll(
+ '标签重命名写入真实存储',
+ () => Promise.resolve(library.listTags().some((tag) => tag.name === '界面标签已重命名'))
+ );
+ uiTag = library.listTags().find((tag) => tag.name === '界面标签已重命名');
+ check(
+ '通过界面重命名标签',
+ !!uiTag && !library.listTags().some((tag) => tag.name === '界面标签')
+ && await js("!!document.querySelector('[aria-label=\"删除界面标签已重命名\"]')")
+ );
+
+ await js(`document.querySelector('#libraryTab .library-filter[data-shelf="__uncategorized__"]').click()`);
+ await pollJs(
+ '未分类筛选刷新',
+ `document.querySelectorAll('#libGrid .card').length === 1
+ && document.querySelector('#libGrid .card-title').textContent === 'Purge Reading Data Book'`
+ );
+ check(
+ '未分类筛选改变卡片和状态',
+ JSON.stringify(await libraryTitles()) === JSON.stringify(['Purge Reading Data Book'])
+ && (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
+ );
+
+ 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.querySelector('#libGrid .card-title').textContent === 'Retained Research Book'`
+ );
+ check(
+ '书架筛选只显示所属书籍和状态',
+ JSON.stringify(await libraryTitles()) === JSON.stringify(['Retained Research Book'])
+ && (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
+ );
+
+ await js(`(() => {
+ const button = Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
+ .find((candidate) => candidate.textContent.includes('# Shared'));
+ button.click();
+ })()`);
+ await pollJs(
+ '标签筛选刷新',
+ "document.querySelectorAll('#libGrid .card').length === 2"
+ );
+ const sharedTitles = await libraryTitles();
+ check(
+ '标签筛选显示两本匹配书籍和状态',
+ sharedTitles.includes('Retained Research Book')
+ && sharedTitles.includes('Selection Source Book')
+ && (await js("document.getElementById('libStatus').textContent")) === '显示 2 条,共 3 条'
+ );
+
+ await js("document.getElementById('addShelfBtn').click()");
+ await waitForModal('新建书架');
+ check(
+ '新建书架使用应用内弹窗',
+ await js("!!document.getElementById('libraryShelfName')")
+ );
+ await js("document.getElementById('libraryShelfName').value = '界面书架'");
+ await submitModal();
+ await poll(
+ '新书架写入真实存储',
+ () => Promise.resolve(library.listShelves().some((shelf) => shelf.name === '界面书架'))
+ );
+ let uiShelf = library.listShelves().find((shelf) => shelf.name === '界面书架');
+ check(
+ '通过界面创建书架并自动选中',
+ !!uiShelf
+ && await js(`(() => {
+ const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
+ .find((candidate) => candidate.textContent.trim() === '界面书架');
+ return !!button && button.classList.contains('active')
+ && document.getElementById('libStatus').textContent === '显示 0 条,共 3 条';
+ })()`)
+ );
+
+ await js(`document.querySelector('[aria-label="重命名界面书架"]').click()`);
+ await waitForModal('重命名书架');
+ check(
+ '重命名弹窗预填原书架名',
+ (await js("document.getElementById('libraryShelfName').value")) === '界面书架'
+ );
+ await js("document.getElementById('libraryShelfName').value = '界面书架已重命名'");
+ await submitModal();
+ await poll(
+ '书架重命名写入真实存储',
+ () => Promise.resolve(library.listShelves().some((shelf) => shelf.name === '界面书架已重命名'))
+ );
+ uiShelf = library.listShelves().find((shelf) => shelf.name === '界面书架已重命名');
+ check(
+ '通过界面重命名书架',
+ !!uiShelf
+ && !library.listShelves().some((shelf) => shelf.name === '界面书架')
+ && await js("!!document.querySelector('[aria-label=\"删除界面书架已重命名\"]')")
+ );
+
+ await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
+ await pollJs(
+ '重置全部书籍筛选',
+ "document.querySelectorAll('#libGrid .card').length === 3"
+ );
+ await js(`document.querySelector(
+ '#libGrid .card[data-id="${purgeBook.id}"] [data-act="organize"]'
+ ).click()`);
+ await waitForModal('整理书籍');
+ check(
+ '整理弹窗提供书架和标签多选下拉',
+ await js(`!!document.getElementById('libraryBookShelf')
+ && document.getElementById('libraryBookTags').tagName === 'DETAILS'
+ && document.querySelectorAll('#libraryBookTags input[type="checkbox"]').length >= 4`)
+ );
+ await js(`(() => {
+ document.getElementById('libraryBookShelf').value = ${JSON.stringify(uiShelf.id)};
+ document.querySelectorAll('#libraryBookTags input[type="checkbox"]').forEach((input) => {
+ input.checked = input.value === 'Shared' || input.value === '界面标签已重命名';
+ });
+ })()`);
+ await submitModal();
+ await poll(
+ '整理操作写入真实存储',
+ () => {
+ const entry = library.get(purgeBook.id);
+ return Promise.resolve(
+ !!entry
+ && entry.shelfId === uiShelf.id
+ && JSON.stringify(entry.tags) === JSON.stringify(['Shared', '界面标签已重命名'])
+ );
+ }
+ );
+ check(
+ '通过整理弹窗分配书架并多选标签',
+ library.get(purgeBook.id).shelfId === uiShelf.id
+ && await js(`(() => {
+ const card = document.querySelector('#libGrid .card[data-id="${purgeBook.id}"]');
+ return Array.from(card.querySelectorAll('.library-card-tag'))
+ .map((tag) => tag.textContent).join('|') === 'Shared|界面标签已重命名';
+ })()`)
+ );
+ check(
+ '整理后标签侧栏聚合计数同步',
+ await js(`(() => {
+ const buttons = Array.from(document.querySelectorAll('#libraryTagList .library-filter'));
+ const shared = buttons.find((button) => button.textContent.includes('# Shared'));
+ const uiTag = buttons.find((button) => button.textContent.includes('# 界面标签已重命名'));
+ return !!shared && shared.querySelector('.library-filter-count').textContent === '3'
+ && !!uiTag && uiTag.querySelector('.library-filter-count').textContent === '1';
+ })()`)
+ );
+
+ await js(`document.querySelector('[aria-label="删除界面标签已重命名"]').click()`);
+ await waitForModal('删除标签');
+ check(
+ '删除标签弹窗说明会从所有书籍移除',
+ (await js("document.getElementById('modalBody').textContent"))
+ .includes('该标签会从所有书籍中移除,书籍不会被删除')
+ );
+ await submitModal();
+ await poll(
+ '删除标签和书籍引用完成',
+ () => Promise.resolve(
+ !library.listTags().some((tag) => tag.id === uiTag.id)
+ && JSON.stringify(library.get(purgeBook.id).tags) === JSON.stringify(['Shared'])
+ )
+ );
+ check(
+ '删除标签不删除书籍',
+ !!library.get(purgeBook.id)
+ && !library.listTags().some((tag) => tag.name === '界面标签已重命名')
+ );
+
+ 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.querySelector('#libGrid .card').dataset.id === ${JSON.stringify(purgeBook.id)}`
+ );
+ await js(`document.querySelector('[aria-label="删除界面书架已重命名"]').click()`);
+ await waitForModal('删除书架');
+ check(
+ '删除书架弹窗说明书籍移至未分类',
+ (await js("document.getElementById('modalBody').textContent"))
+ .includes('书籍不会被删除,将移至「未分类」')
+ );
+ await submitModal();
+ await poll(
+ '删除书架完成',
+ () => Promise.resolve(!library.listShelves().some((shelf) => shelf.id === uiShelf.id))
+ );
+ await pollJs(
+ '删除书架后未分类视图刷新',
+ `document.querySelectorAll('#libGrid .card').length === 1
+ && document.querySelector('#libGrid .card').dataset.id === ${JSON.stringify(purgeBook.id)}`
+ );
+ check(
+ '删除书架将书籍移至未分类',
+ library.get(purgeBook.id).shelfId === null
+ && await js(`document.querySelector(
+ '#libraryTab .library-filter[data-shelf="__uncategorized__"]'
+ ).classList.contains('active')`)
+ && (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
+ );
+
+ await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
+ await pollJs(
+ '我的笔记页渲染完成',
+ "document.querySelectorAll('#notesList .note-card').length === 3"
+ );
+ const initialCards = await noteCards();
+ check(
+ '我的笔记显示正文、摘录、书名和笔记本',
+ initialCards.some((card) =>
+ card.book === 'Retained Research Book'
+ && card.text === 'Alpha insight searchable body'
+ && card.meta.includes('研究笔记本'))
+ && initialCards.some((card) =>
+ card.book === 'Selection Source Book'
+ && card.quote === 'Quoted selection excerpt')
+ && (await js("document.getElementById('notesStatus').textContent")) === '共 3 条笔记'
+ );
+ check(
+ '笔记卡使用网格视图并显示类型、来源和置顶状态',
+ initialCards[0].title === 'Pinned Research Note'
+ && initialCards[0].pinned === true
+ && initialCards.every((card) => card.type === '读书笔记')
+ && initialCards[0].source === '人工'
+ && initialCards.some((card) => card.title === 'Selected Passage' && card.source === '摘录')
+ && await js(`getComputedStyle(document.getElementById('notesList')).display === 'grid'`)
+ );
+ check(
+ '笔记类型 Tab 提供全部、画布笔记和读书笔记',
+ await js(`Array.from(document.querySelectorAll('#notesTypeTabs .notes-type-tab'))
+ .map((button) => button.textContent.trim()).join(',') === '全部,画布笔记,读书笔记'`)
+ );
+ await js(`document.querySelector('#notesTypeTabs [data-note-type="canvas"]').click()`);
+ check(
+ '画布笔记 Tab 在尚无画布笔记时显示空状态',
+ (await noteCards()).length === 0
+ && (await js("document.getElementById('notesStatus').textContent")) === '显示 0 条,共 3 条笔记'
+ );
+ await js(`document.querySelector('#notesTypeTabs [data-note-type="reading"]').click()`);
+ check(
+ '读书笔记 Tab 仅显示读书笔记',
+ (await noteCards()).length === 3
+ && (await noteCards()).every((card) => card.type === '读书笔记')
+ );
+ await js(`document.querySelector('#notesTypeTabs [data-note-type=""]').click()`);
+
+ await js(`(() => {
+ document.getElementById('notesSearchInput').value = 'Quoted selection excerpt';
+ document.getElementById('notesSearchBtn').click();
+ })()`);
+ await pollJs(
+ '笔记搜索筛选刷新',
+ "document.querySelectorAll('#notesList .note-card').length === 1"
+ );
+ check(
+ '搜索筛选命中摘录文本',
+ (await noteCards())[0].title === 'Selected Passage'
+ && (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
+ );
+ await js("document.getElementById('notesClearSearchBtn').click()");
+ await pollJs(
+ '清除笔记搜索',
+ "document.querySelectorAll('#notesList .note-card').length === 3"
+ );
+
+ await js(`(() => {
+ const select = document.getElementById('notesSourceSelect');
+ select.value = 'selection';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ })()`);
+ check(
+ '来源筛选只显示摘录笔记',
+ (await noteCards()).length === 1
+ && (await noteCards())[0].title === 'Selected Passage'
+ && (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
+ );
+ await js(`(() => {
+ const select = document.getElementById('notesSourceSelect');
+ select.value = '';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ })()`);
+
+ await js(`(() => {
+ const tag = Array.from(document.querySelectorAll('#notesTagFilters .note-tag'))
+ .find((button) => button.textContent === 'focus');
+ tag.click();
+ })()`);
+ check(
+ '标签筛选只显示匹配笔记',
+ (await noteCards()).length === 1
+ && (await noteCards())[0].title === 'Pinned Research Note'
+ && (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
+ );
+ await js(`(() => {
+ const tag = Array.from(document.querySelectorAll('#notesTagFilters .note-tag'))
+ .find((button) => button.textContent === 'focus');
+ tag.click();
+ })()`);
+
+ await js(`(() => {
+ const notebook = Array.from(document.querySelectorAll('#notesCollectionList .notes-collection'))
+ .find((button) => button.textContent.trim() === '研究笔记本');
+ notebook.click();
+ })()`);
+ check(
+ '笔记本筛选只显示所属笔记',
+ (await noteCards()).length === 1
+ && (await noteCards())[0].title === 'Pinned Research Note'
+ && (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
+ );
+ await js(`document.querySelector('#notesTab .notes-collection[data-collection=""]').click()`);
+
+ await js(`(() => {
+ const card = Array.from(document.querySelectorAll('#notesList .note-card'))
+ .find((candidate) => {
+ const title = candidate.querySelector('.note-title');
+ return title && title.textContent === 'Pinned Research Note';
+ });
+ card.querySelector('.note-action.edit').click();
+ })()`);
+ await waitForModal('编辑读书笔记');
+ check(
+ 'Quill 段落选择与格式按钮保持同一行且位于 B/I 前方',
+ await js(`(() => {
+ const toolbar = document.querySelector('#noteEditRich .rich-note-toolbar');
+ const picker = toolbar.querySelector('.ql-picker.ql-header');
+ const bold = toolbar.querySelector('.ql-bold');
+ const options = picker.querySelector('.ql-picker-options');
+ return !!picker && !!bold
+ && (picker.compareDocumentPosition(bold) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
+ && getComputedStyle(toolbar).flexWrap === 'nowrap'
+ && getComputedStyle(options).backgroundColor
+ === getComputedStyle(document.querySelector('.note-card')).backgroundColor;
+ })()`)
+ );
+ await js(`(() => {
+ document.getElementById('noteEditTitle').value = 'Edited Research Note';
+ const quill = Quill.find(document.querySelector('#noteEditRich .rich-note-quill'));
+ quill.setText('Edited alpha insight body');
+ quill.formatText(0, 'Edited alpha insight body'.length, 'bold', true);
+ document.getElementById('noteEditCollection').value = ${JSON.stringify(notebook.id)};
+ document.getElementById('noteEditTags').value = 'focus, edited';
+ document.getElementById('noteEditPinned').checked = false;
+ const bytes = Uint8Array.from(atob(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z9WQAAAAASUVORK5CYII='
+ ), (char) => char.charCodeAt(0));
+ const transfer = new DataTransfer();
+ transfer.items.add(new File([bytes], 'note-image.png', { type: 'image/png' }));
+ const input = document.querySelector('#noteEditRich input[type="file"]');
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change'));
+ })()`);
+ await pollJs(
+ '富文本编辑器插入图片',
+ "document.querySelectorAll('#noteEditRich .ql-editor img').length === 1"
+ );
+ await submitModal();
+ await poll(
+ '编辑笔记写入真实存储',
+ () => {
+ const note = readerStore.listNotes({ entryId: retainedBook.id })
+ .find((candidate) => candidate.id === manualNote.id);
+ return Promise.resolve(
+ !!note
+ && note.title === 'Edited Research Note'
+ && note.text === 'Edited alpha insight body'
+ && note.richContent.ops.some((op) => op.insert && op.insert.image)
+ && note.richContent.ops.some((op) => (
+ op.attributes && op.attributes.bold === true
+ && op.insert === 'Edited alpha insight body'
+ ))
+ && note.collectionId === notebook.id
+ && JSON.stringify(note.tags) === JSON.stringify(['focus', 'edited'])
+ && note.pinned === false
+ );
+ }
+ );
+ check(
+ '通过界面编辑标题、正文、笔记本、标签和置顶状态',
+ (await noteCards()).some((card) =>
+ card.title === 'Edited Research Note'
+ && card.text === 'Edited alpha insight body'
+ && card.pinned === false
+ && card.tags.join('|') === 'focus|edited')
+ );
+ check(
+ '富文本正文和内嵌图片安全渲染在笔记卡片',
+ await js(`(() => {
+ const card = Array.from(document.querySelectorAll('#notesList .note-card'))
+ .find((candidate) => candidate.querySelector('.note-title')?.textContent === 'Edited Research Note');
+ const image = card && card.querySelector('.note-text img');
+ return !!image
+ && image.src.startsWith('data:image/png;base64,')
+ && card.querySelector('.note-text strong')?.textContent === 'Edited alpha insight body';
+ })()`)
+ );
+
+ await js(`(() => {
+ const card = Array.from(document.querySelectorAll('#notesList .note-card'))
+ .find((candidate) => {
+ const title = candidate.querySelector('.note-title');
+ return title && title.textContent === 'Selected Passage';
+ });
+ card.querySelector('.note-action.delete').click();
+ })()`);
+ await waitForModal('删除笔记');
+ check(
+ '删除笔记使用应用内确认弹窗',
+ (await js("document.getElementById('modalBody').textContent")).includes('此操作无法撤销')
+ );
+ await submitModal();
+ await poll(
+ '删除笔记写入真实存储',
+ () => Promise.resolve(
+ !readerStore.listNotes({ entryId: selectionBook.id })
+ .some((note) => note.id === selectionNote.id)
+ )
+ );
+ await pollJs(
+ '删除笔记后页面刷新',
+ "document.querySelectorAll('#notesList .note-card').length === 2"
+ );
+ check(
+ '通过界面删除摘录笔记',
+ !(await noteCards()).some((card) => card.title === 'Selected Passage')
+ && (await js("document.getElementById('notesStatus').textContent")) === '共 2 条笔记'
+ );
+
+ await js("document.getElementById('addGlobalNoteBtn').click()");
+ await waitForModal('选择笔记类型');
+ check(
+ '新建笔记先选择读书笔记或画布笔记',
+ await js(`Array.from(document.querySelectorAll('.note-type-choice-title'))
+ .map((item) => item.textContent.trim()).join(',') === '读书笔记,画布笔记'`)
+ );
+ await js("document.getElementById('modalOk').click()");
+ await waitForModal('新建读书笔记');
+ check(
+ '新建读书笔记可选择关联书籍',
+ await js(`(() => {
+ const select = document.getElementById('newNoteBook');
+ return !!select && Array.from(select.options)
+ .some((option) => option.value === ${JSON.stringify(retainedBook.id)}
+ && option.textContent === 'Retained Research Book');
+ })()`)
+ );
+ await js(`(() => {
+ document.getElementById('newNoteBook').value = ${JSON.stringify(retainedBook.id)};
+ document.getElementById('newNoteTitle').value = 'UI Created Note';
+ Quill.find(document.querySelector('#newNoteRich .rich-note-quill'))
+ .setText('Created directly from My Notes page');
+ document.getElementById('newNoteCollection').value = ${JSON.stringify(notebook.id)};
+ document.getElementById('newNoteTags').value = 'ui-created, focus';
+ document.getElementById('newNotePinned').checked = true;
+ })()`);
+ await submitModal();
+ let createdNote = null;
+ await poll(
+ '页面新建笔记写入真实存储',
+ () => {
+ createdNote = readerStore.listNotes({ entryId: retainedBook.id })
+ .find((note) => note.text === 'Created directly from My Notes page') || null;
+ return Promise.resolve(!!createdNote);
+ }
+ );
+ await pollJs(
+ '页面新建笔记渲染完成',
+ `Array.from(document.querySelectorAll('#notesList .note-title'))
+ .some((title) => title.textContent === 'UI Created Note')`
+ );
+ check(
+ '页面新建笔记保存关联、标题、正文、笔记本、标签和置顶',
+ createdNote.title === 'UI Created Note'
+ && createdNote.entryId === retainedBook.id
+ && createdNote.collectionId === notebook.id
+ && JSON.stringify(createdNote.tags) === JSON.stringify(['ui-created', 'focus'])
+ && createdNote.pinned === true
+ && createdNote.noteType === 'reading'
+ && !createdNote.canvasContent
+ && await js(`Array.from(document.querySelectorAll('.note-badge.type'))
+ .some((item) => item.textContent === '读书笔记')`)
+ && (await noteCards())[0].title === 'UI Created Note'
+ );
+
+ await js(`document.querySelector('.tab[data-tab="library"]').click()`);
+ await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
+ await pollJs(
+ '返回全部书籍视图',
+ "document.querySelectorAll('#libGrid .card').length === 3"
+ );
+ check(
+ '匹配书库卡片显示真实笔记数徽标',
+ await js(`(() => {
+ const badge = document.querySelector(
+ '#libGrid .card[data-id="${retainedBook.id}"] .card-badge.note-count'
+ );
+ return !!badge && badge.textContent.trim() === '笔记 2';
+ })()`)
+ );
+
+ await js(`document.querySelector(
+ '#libGrid .card[data-id="${retainedBook.id}"] [data-act="remove"]'
+ ).click()`);
+ await waitForModal('移除条目');
+ check(
+ '移除弹窗两个删除选项默认均未勾选',
+ await js(`(() => {
+ const files = document.getElementById('delFiles');
+ const reading = document.getElementById('delReadingData');
+ return !!files && !files.checked && !!reading && !reading.checked;
+ })()`)
+ );
+ check(
+ '移除弹窗明确说明默认保留阅读资料',
+ (await js("document.getElementById('modalBody').textContent"))
+ .includes('默认保留阅读资料,移除后仍可在「我的笔记」中查看')
+ );
+ await submitModal();
+ await poll(
+ '保留阅读资料移除书籍',
+ () => Promise.resolve(!library.get(retainedBook.id))
+ );
+ await pollJs(
+ '保留阅读资料移除后书库刷新',
+ `!document.querySelector('#libGrid .card[data-id="${retainedBook.id}"]')
+ && document.querySelectorAll('#libGrid .card').length === 2`
+ );
+ check(
+ '默认移除保留该书全部笔记',
+ readerStore.listNotes({ entryId: retainedBook.id }).length === 2
+ && fs.existsSync(FIXTURE_FILE)
+ );
+
+ await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
+ await pollJs(
+ '默认移除后聚合笔记仍显示',
+ "document.querySelectorAll('#notesList .note-card').length === 3"
+ );
+ check(
+ '已移除书籍的笔记仍保留书名并出现在聚合页',
+ (await noteCards()).filter((card) => card.book === 'Retained Research Book').length === 2
+ && (await js("document.getElementById('notesStatus').textContent")) === '共 3 条笔记'
+ );
+
+ await js(`document.querySelector('.tab[data-tab="library"]').click()`);
+ await pollJs(
+ '返回书库执行显式阅读数据删除',
+ `!!document.querySelector('#libGrid .card[data-id="${purgeBook.id}"]')`
+ );
+ await js(`document.querySelector(
+ '#libGrid .card[data-id="${purgeBook.id}"] [data-act="remove"]'
+ ).click()`);
+ await waitForModal('移除条目');
+ check(
+ '无文件条目仍提供阅读数据删除且默认未勾选',
+ await js(`!document.getElementById('delFiles')
+ && !!document.getElementById('delReadingData')
+ && !document.getElementById('delReadingData').checked`)
+ );
+ await js("document.getElementById('delReadingData').checked = true");
+ await submitModal();
+ await poll(
+ '显式删除阅读数据完成',
+ () => Promise.resolve(
+ !library.get(purgeBook.id)
+ && readerStore.listNotes({ entryId: purgeBook.id }).length === 0
+ )
+ );
+ check(
+ '勾选阅读数据删除会移除第二本书的笔记',
+ !library.get(purgeBook.id)
+ && readerStore.listNotes({ entryId: purgeBook.id }).length === 0
+ );
+
+ await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
+ await pollJs(
+ '显式删除阅读数据后笔记页刷新',
+ "document.querySelectorAll('#notesList .note-card').length === 2"
+ );
+ check(
+ '显式删除后聚合页仅保留默认保留的笔记',
+ (await noteCards()).every((card) => card.book === 'Retained Research Book')
+ && !(await noteCards()).some((card) => card.title === 'Purge With Reading Data')
+ && (await js("document.getElementById('notesStatus').textContent")) === '共 2 条笔记'
+ );
+
+ await js("document.getElementById('addGlobalNoteBtn').click()");
+ await waitForModal('选择笔记类型');
+ await js(`(() => {
+ document.querySelector('input[name="newNoteType"][value="canvas"]').checked = true;
+ document.getElementById('modalOk').click();
+ })()`);
+ await waitForModal('新建画布笔记');
+ await pollJs(
+ '独立画布笔记编辑器加载完成',
+ "!!document.querySelector('#newNoteRich .canvas-note-root')",
+ 15000
+ );
+ check(
+ '主窗口画布仅工作区滚动且工具栏使用一致的图标按钮',
+ await js(`(() => {
+ const viewport = document.querySelector('#newNoteRich .canvas-note-viewport');
+ const toolbar = document.querySelector('#newNoteRich .canvas-note-toolbar');
+ const buttons = [...toolbar.querySelectorAll('.canvas-note-button')];
+ const scrollables = [];
+ for (let node = viewport; node && node.id !== 'modal'; node = node.parentElement) {
+ const style = getComputedStyle(node);
+ if (/auto|scroll/.test(style.overflowX) || /auto|scroll/.test(style.overflowY)) {
+ scrollables.push(node);
+ }
+ }
+ return scrollables.length === 1
+ && scrollables[0] === viewport
+ && getComputedStyle(document.getElementById('modalBody')).overflowY === 'hidden'
+ && getComputedStyle(toolbar).flexWrap === 'wrap'
+ && getComputedStyle(toolbar).overflowX === 'visible'
+ && buttons.length >= 10
+ && buttons.every((button) => !!button.querySelector('.canvas-note-icon'))
+ && viewport.clientHeight > 0;
+ })()`)
+ );
+ check(
+ '新建画布笔记允许选择不关联书籍',
+ await js(`(() => {
+ const select = document.getElementById('newNoteBook');
+ return !!select && select.value === ''
+ && select.options[0].textContent === '不关联书籍';
+ })()`)
+ );
+ await js(`(() => {
+ document.querySelector('#newNoteRich [data-tool="flow-text"]').click();
+ const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
+ const text = Array.from({ length: 90 }, (_, index) =>
+ \`第 \${index + 1} 段全局正文会在纸张边界自动流入下一页。\`
+ ).join('\\n');
+ quill.setText(text, 'user');
+ quill.setSelection(quill.getLength() - 1, 0, 'silent');
+ })()`);
+ await pollJs(
+ '全局文本超过当前纸张后自动分页',
+ `Number(document.querySelector('#newNoteRich .canvas-note-page-counter')
+ .textContent.split('/')[1]) > 1`,
+ 15000
+ );
+ await js("document.querySelector('#newNoteRich .canvas-note-undo').click()");
+ await pollJs(
+ '全局文本使用画布统一撤销',
+ `Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill')).getText().trim() === ''`,
+ 10000
+ );
+ await js("document.querySelector('#newNoteRich .canvas-note-redo').click()");
+ await pollJs(
+ '全局文本使用画布统一重做',
+ `Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
+ .getText().includes('第 90 段全局正文')`,
+ 10000
+ );
+ await js(`(() => {
+ const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
+ quill.formatText(0, 8, 'bold', true, 'user');
+ quill.formatLine(0, 1, 'header', 1, 'user');
+ })()`);
+ const flowBeforePageOperations = await js(`(() => {
+ const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
+ return {
+ text: quill.getText(),
+ pages: Number(document.querySelector('#newNoteRich .canvas-note-page-counter')
+ .textContent.split('/')[1])
+ };
+ })()`);
+ await js(`(() => {
+ const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
+ quill.setSelection(300, 0, 'silent');
+ document.querySelector('#newNoteRich .canvas-note-add-page').click();
+ })()`);
+ await pollJs(
+ '全局文本添加页面写入显式分页符',
+ `Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
+ .getContents().ops.some((op) => op.insert && op.insert.canvasPageBreak)`,
+ 10000
+ );
+ await wait(600);
+ await pollJs(
+ '添加显式页面操作完成',
+ `!document.querySelector('#newNoteRich .canvas-note-delete-page').disabled`,
+ 10000
+ );
+ check(
+ '显式分页符把后续正文移动到下一张纸',
+ await js(`(() => {
+ const pageBreak = document.querySelector('#newNoteRich .canvas-flow-page-break');
+ const nextBlock = pageBreak?.nextElementSibling;
+ return !!nextBlock
+ && nextBlock.getBoundingClientRect().left
+ - pageBreak.getBoundingClientRect().left > 300;
+ })()`)
+ );
+ await js("document.querySelector('#newNoteRich .canvas-note-delete-page').click()");
+ await pollJs(
+ '删除空白显式页面仅移除分页符',
+ `!Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
+ .getContents().ops.some((op) => op.insert && op.insert.canvasPageBreak)`,
+ 10000
+ );
+ const flowAfterPageOperations = await js(`(() => {
+ const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
+ return quill.getText();
+ })()`);
+ check(
+ '添加和删除页面不会删除全局正文',
+ flowAfterPageOperations === flowBeforePageOperations.text,
+ `${flowBeforePageOperations.text.length} -> ${flowAfterPageOperations.length}`
+ );
+ await js(`(() => {
+ const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
+ quill.setSelection(600, 0, 'silent');
+ document.querySelector('#newNoteRich .canvas-note-add-page').click();
+ })()`);
+ await pollJs(
+ '显式分页符保留到保存内容',
+ `Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
+ .getContents().ops.some((op) => op.insert && op.insert.canvasPageBreak)`,
+ 10000
+ );
+ await wait(600);
+ await pollJs(
+ '再次添加显式页面操作完成',
+ `!document.querySelector('#newNoteRich .canvas-note-import-pdf').disabled`,
+ 10000
+ );
+ await js(`(() => {
+ document.getElementById('newNoteTitle').value = 'Standalone Note';
+ document.getElementById('newNoteTags').value = 'standalone';
+ })()`);
+ const originalPdfPicker = dialog.showOpenDialog;
+ dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [FIXTURE_PDF] });
+ await js("document.querySelector('#newNoteRich .canvas-note-import-pdf').click()");
+ await pollJs(
+ 'PDF 导入为可标注底版',
+ `document.querySelector('#newNoteRich .canvas-note-template').value === '__pdf'
+ && document.querySelector('#newNoteRich .canvas-note-background').width > 0
+ && !document.querySelector('#newNoteRich .canvas-note-export-pdf').disabled`,
+ 20000
+ );
+ dialog.showOpenDialog = originalPdfPicker;
+
+ const exportedCanvasPdf = path.join(TMP, 'exported-canvas-note.pdf');
+ const originalSaveDialog = dialog.showSaveDialog;
+ dialog.showSaveDialog = async () => ({ canceled: false, filePath: exportedCanvasPdf });
+ await js("document.querySelector('#newNoteRich .canvas-note-export-pdf').click()");
+ await poll(
+ '自由画布导出有效 PDF',
+ async () => {
+ if (fs.existsSync(exportedCanvasPdf)
+ && fs.readFileSync(exportedCanvasPdf).subarray(0, 5).toString() === '%PDF-') {
+ return true;
+ }
+ const error = await js("document.getElementById('newNoteError').textContent");
+ if (error) throw new Error(error);
+ return false;
+ },
+ 20000
+ );
+ dialog.showSaveDialog = originalSaveDialog;
+ await submitModal();
+ let standaloneNote = null;
+ await poll(
+ '无关联笔记写入独立存储',
+ () => {
+ standaloneNote = readerStore.listNotes({
+ entryId: readerStore.STANDALONE_ENTRY_ID
+ }).find((note) => note.title === 'Standalone Note') || null;
+ return Promise.resolve(!!standaloneNote);
+ }
+ );
+ await pollJs(
+ '无关联笔记渲染完成',
+ `Array.from(document.querySelectorAll('#notesList .note-title'))
+ .some((title) => title.textContent === 'Standalone Note')`
+ );
+ await js(`(() => {
+ const title = Array.from(document.querySelectorAll('#notesList .note-title'))
+ .find((item) => item.textContent === 'Standalone Note');
+ title.closest('.note-card').querySelector('.note-action.edit').click();
+ })()`);
+ await waitForModal('编辑画布笔记');
+ await pollJs(
+ '重开画布笔记恢复全局正文和显式分页符',
+ `Quill.find(document.querySelector('#noteEditRich .canvas-flow-quill'))
+ .getText().includes('第 90 段全局正文')
+ && !!document.querySelector('#noteEditRich .canvas-flow-page-break')`,
+ 15000
+ );
+ await js("document.getElementById('modalCancel').click()");
+ await pollJs('关闭重开画布笔记弹窗', "document.getElementById('modal').classList.contains('hidden')");
+ check(
+ '无关联笔记显示明确状态且不提供原文按钮',
+ standaloneNote.associated === false
+ && standaloneNote.noteType === 'canvas'
+ && standaloneNote.canvasContent.version === 2
+ && standaloneNote.canvasContent.flow.ops.some((op) => (
+ typeof op.insert === 'string' && op.insert.includes('全局正文')
+ ))
+ && standaloneNote.canvasContent.flow.ops.some((op) => op.attributes?.bold === true)
+ && standaloneNote.canvasContent.flow.ops.some((op) => op.attributes?.header === 1)
+ && standaloneNote.canvasContent.flow.ops.some((op) => (
+ op.insert?.canvasPageBreak
+ && standaloneNote.canvasContent.pages.some(
+ (page) => page.id === op.insert.canvasPageBreak
+ )
+ ))
+ && standaloneNote.canvasContent.pages.some((page) => page.background.type === 'pdf')
+ && /^pdf_[a-f0-9]{64}$/.test(
+ standaloneNote.canvasContent.pages.find(
+ (page) => page.background.type === 'pdf'
+ ).background.assetId
+ )
+ && !Object.prototype.hasOwnProperty.call(
+ standaloneNote.canvasContent.pages.find(
+ (page) => page.background.type === 'pdf'
+ ).background,
+ 'draftToken'
+ )
+ && readerStore.noteAssetIds().includes(
+ standaloneNote.canvasContent.pages.find(
+ (page) => page.background.type === 'pdf'
+ ).background.assetId
+ )
+ && (await noteCards()).some((card) => (
+ card.title === 'Standalone Note' && card.book === '未关联书籍'
+ ))
+ && await js(`(() => {
+ const title = Array.from(document.querySelectorAll('#notesList .note-title'))
+ .find((item) => item.textContent === 'Standalone Note');
+ const card = title && title.closest('.note-card');
+ return !!card && !card.querySelector('.note-action.open');
+ })()`)
+ );
+ await js(`document.querySelector('#notesTypeTabs [data-note-type="canvas"]').click()`);
+ check(
+ '画布笔记 Tab 只显示画布卡片',
+ (await noteCards()).length === 1
+ && (await noteCards())[0].title === 'Standalone Note'
+ && (await noteCards())[0].type === '画布笔记'
+ && await js(`!!document.querySelector('#notesList .note-canvas-preview')`)
+ );
+ await js(`document.querySelector('#notesTypeTabs [data-note-type="reading"]').click()`);
+ check(
+ '读书笔记 Tab 隐藏画布卡片',
+ (await noteCards()).length === 2
+ && (await noteCards()).every((card) => card.type === '读书笔记')
+ && (await noteCards()).some((card) => card.title === 'Edited Research Note')
+ );
+ await js(`document.querySelector('#notesTypeTabs [data-note-type=""]').click()`);
+
+ const importRoot = path.join(TMP, 'existing-folder-layout');
+ const literatureDir = path.join(importRoot, '旧文学分类');
+ const technologyDir = path.join(importRoot, '旧技术分类');
+ fs.mkdirSync(literatureDir, { recursive: true });
+ fs.mkdirSync(technologyDir, { recursive: true });
+ fs.writeFileSync(path.join(literatureDir, 'Local Novel.epub'), 'epub fixture');
+ fs.writeFileSync(path.join(technologyDir, 'Local Manual.pdf'), 'pdf fixture');
+ const originalShowOpenDialog = dialog.showOpenDialog;
+ dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [importRoot] });
+ await js("document.getElementById('addLocalBtn').click()");
+ await waitForModal('添加本地内容');
+ await js(`(() => {
+ document.querySelector('input[name="localImportSource"][value="folder"]').checked = true;
+ document.getElementById('modalOk').click();
+ })()`);
+ await waitForModal('导入本地图书');
+ check(
+ '本地导入支持递归文件夹和上级目录组织选项',
+ await js(`document.getElementById('modalBody').textContent.includes('发现 2 个支持的图书文件')
+ && !!document.querySelector('input[name="localImportOrganization"][value="shelf"]')
+ && !!document.querySelector('input[name="localImportOrganization"][value="tag"]')`)
+ );
+ await js(`document.querySelector(
+ 'input[name="localImportOrganization"][value="shelf"]'
+ ).checked = true`);
+ await submitModal();
+ await poll(
+ '文件夹导入写入书库和上级目录书架',
+ () => Promise.resolve(
+ !!library.list().find((item) => item.title === 'Local Novel')
+ && !!library.list().find((item) => item.title === 'Local Manual')
+ && library.listShelves().some((shelf) => shelf.name === '旧文学分类')
+ && library.listShelves().some((shelf) => shelf.name === '旧技术分类')
+ )
+ );
+ const localNovel = library.list().find((item) => item.title === 'Local Novel');
+ const literatureShelf = library.listShelves().find((shelf) => shelf.name === '旧文学分类');
+ check(
+ '文件的上一级目录被复用为书架分类',
+ localNovel.shelfId === literatureShelf.id
+ && library.list().filter((item) => item.importedByLocal).length === 2
+ );
+ const repeatSelection = await js("window.api.library.pickLocal('folder')");
+ const repeatId = JSON.stringify(repeatSelection.data.selectionId);
+ const repeatImport = await js(
+ `window.api.library.importLocal(${repeatId}, { organization: 'shelf' })`
+ );
+ const replayImport = await js(
+ `window.api.library.importLocal(${repeatId}, { organization: 'shelf' })`
+ );
+ check(
+ '重复文件被跳过且本地选择令牌不能重放',
+ repeatImport.ok
+ && repeatImport.data.added === 0
+ && repeatImport.data.skipped === 2
+ && !replayImport.ok
+ && replayImport.error.includes('已失效')
+ );
+ dialog.showOpenDialog = originalShowOpenDialog;
+
+ await wait(300);
+ check(
+ '主渲染进程没有控制台错误',
+ rendererErrors.length === 0,
+ rendererErrors.slice(0, 3).join(' | ')
+ );
+
+ finish();
+}).catch((error) => {
+ console.error('异常:', error && error.stack ? error.stack : error);
+ check('集成脚本无未处理异常', false, error && error.message ? error.message : String(error));
+ finish();
+});
diff --git a/src/_test/electron/reader-features.integration.js b/src/_test/electron/reader-features.integration.js
new file mode 100644
index 0000000..8ad6c26
--- /dev/null
+++ b/src/_test/electron/reader-features.integration.js
@@ -0,0 +1,1411 @@
+const { app, BrowserWindow, dialog, safeStorage, nativeImage } = require('electron');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const JSZip = require('jszip');
+
+const ROOT = path.resolve(__dirname, '..', '..', '..');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-reader-features-'));
+const PDF_FILE = path.join(TMP, 'reader-features.pdf');
+const EPUB_FILE = path.join(TMP, 'reader-features.epub');
+const MOBI_FILE = path.join(TMP, 'reader-features.mobi');
+const DRM_MOBI_FILE = path.join(TMP, 'reader-features-drm.azw');
+const LARGE_EPUB_FILE = path.join(TMP, 'reader-features-large.epub');
+app.setPath('userData', TMP);
+app.setPath('appData', TMP);
+
+const results = [];
+function check(name, condition, detail = '') {
+ results.push([condition ? 'OK' : 'FAIL', name, detail]);
+}
+
+function imageHasInk(dataUrl) {
+ const image = nativeImage.createFromDataURL(String(dataUrl || ''));
+ if (image.isEmpty()) return false;
+ const bitmap = image.toBitmap();
+ let dark = 0;
+ for (let offset = 0; offset + 3 < bitmap.length; offset += 200) {
+ if (bitmap[offset] < 238 || bitmap[offset + 1] < 238 || bitmap[offset + 2] < 238) dark++;
+ }
+ return dark >= 8;
+}
+
+function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function waitUntil(predicate, timeout = 10000, interval = 100) {
+ const deadline = Date.now() + timeout;
+ let lastError = null;
+ while (Date.now() < deadline) {
+ try {
+ const value = await predicate();
+ if (value) return value;
+ } catch (error) {
+ lastError = error;
+ }
+ await wait(interval);
+ }
+ if (lastError) throw lastError;
+ throw new Error(`等待条件超时(${timeout}ms)`);
+}
+
+function makePdf(file) {
+ const pageText = [
+ 'Page One reader integration fixture.',
+ 'Page Two selectable text validates excerpt and note storage.',
+ 'Page Three reader integration fixture.'
+ ];
+ const objects = new Array(10);
+ objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
+ objects[2] = '<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>';
+ for (let i = 0; i < 3; i++) {
+ objects[3 + i] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents ${7 + i} 0 R >>`;
+ }
+ objects[6] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
+ for (let i = 0; i < 3; i++) {
+ const stream = [
+ 'BT',
+ '/F1 18 Tf',
+ '72 720 Td',
+ `(${pageText[i]}) Tj`,
+ '0 -34 Td',
+ '(Touch gestures must preserve the focal document location.) Tj',
+ 'ET',
+ ''
+ ].join('\n');
+ objects[7 + i] = `<< /Length ${Buffer.byteLength(stream, 'ascii')} >>\nstream\n${stream}endstream`;
+ }
+
+ let pdf = '%PDF-1.4\n';
+ const offsets = new Array(objects.length).fill(0);
+ for (let i = 1; i < objects.length; i++) {
+ offsets[i] = Buffer.byteLength(pdf, 'ascii');
+ pdf += `${i} 0 obj\n${objects[i]}\nendobj\n`;
+ }
+ const xref = Buffer.byteLength(pdf, 'ascii');
+ pdf += `xref\n0 ${objects.length}\n`;
+ pdf += '0000000000 65535 f \n';
+ for (let i = 1; i < objects.length; i++) {
+ pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
+ }
+ pdf += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+ fs.writeFileSync(file, pdf, 'ascii');
+}
+
+async function makeEpub(file) {
+ const zip = new JSZip();
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
+ zip.file('META-INF/container.xml', `
+
+
+
+
+ `);
+ zip.file('OEBPS/content.opf', `
+
+
+ reader-features-fixture
+ Reader Features EPUB Fixture
+ en
+
+
+
+
+
+
+ `);
+ const paragraphs = Array.from({ length: 90 }, (_, index) => (
+ `Paragraph ${index + 1}. EPUB focal anchor sentence ${index + 1} keeps a nearby character offset after a synthetic pinch gesture. Selection excerpt text remains available to reader notes.${index === 0 ? ' Jump to middle section ' : ''}
`
+ )).join('\n');
+ zip.file('OEBPS/chapter.xhtml', `
+
+
+ Touch Chapter
+ Touch Chapter ${paragraphs}
+`);
+ zip.file('OEBPS/nav.xhtml', `
+
+ Contents
+
+ Start
+ Middle Section
+
+`);
+ fs.writeFileSync(file, await zip.generateAsync({
+ type: 'nodebuffer',
+ mimeType: 'application/epub+zip',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 6 }
+ }));
+}
+
+function makeMobi(file) {
+ const text = Buffer.from(
+ 'MOBI Chapter '
+ + 'MOBI Chapter MOBI selectable text validates the Foliate parser, '
+ + 'built-in rendering, bookmarks, excerpts, notes, and stable reading progress.
'
+ + ''
+ + ' '
+ + 'unsafe link '
+ + '',
+ 'utf8'
+ );
+ const compressed = Buffer.concat(Array.from(
+ { length: Math.ceil(text.length / 8) },
+ (_, index) => {
+ const chunk = text.subarray(index * 8, index * 8 + 8);
+ return Buffer.concat([Buffer.from([chunk.length]), chunk]);
+ }
+ ));
+ const record0Length = 320;
+ const record0Offset = 96;
+ const record1Offset = record0Offset + record0Length;
+ const output = Buffer.alloc(record1Offset + compressed.length);
+ output.write('Reader Features MOBI', 0, 'ascii');
+ output.write('BOOK', 60, 'ascii');
+ output.write('MOBI', 64, 'ascii');
+ output.writeUInt16BE(2, 76);
+ output.writeUInt32BE(record0Offset, 78);
+ output.writeUInt32BE(record1Offset, 86);
+ output.writeUInt16BE(2, record0Offset);
+ output.writeUInt16BE(1, record0Offset + 8);
+ output.writeUInt16BE(4096, record0Offset + 10);
+ output.writeUInt16BE(0, record0Offset + 12);
+ output.write('MOBI', record0Offset + 16, 'ascii');
+ output.writeUInt32BE(248, record0Offset + 20);
+ output.writeUInt32BE(2, record0Offset + 24);
+ output.writeUInt32BE(65001, record0Offset + 28);
+ output.writeUInt32BE(12345, record0Offset + 32);
+ output.writeUInt32BE(6, record0Offset + 36);
+ const title = Buffer.from('Reader Features MOBI Fixture', 'utf8');
+ output.writeUInt32BE(270, record0Offset + 84);
+ output.writeUInt32BE(title.length, record0Offset + 88);
+ output[record0Offset + 94] = 0;
+ output[record0Offset + 95] = 9;
+ output.writeUInt32BE(2, record0Offset + 108);
+ output.writeUInt32BE(0xffffffff, record0Offset + 112);
+ output.writeUInt32BE(0, record0Offset + 116);
+ output.writeUInt32BE(0, record0Offset + 128);
+ output.writeUInt32BE(0, record0Offset + 240);
+ output.writeUInt32BE(0xffffffff, record0Offset + 244);
+ title.copy(output, record0Offset + 270);
+ compressed.copy(output, record1Offset);
+ fs.writeFileSync(file, output);
+}
+
+async function js(win, source) {
+ try {
+ return await win.webContents.executeJavaScript(source);
+ } catch (error) {
+ console.error('脚本失败:', source.slice(0, 180), error.message);
+ throw error;
+ }
+}
+
+async function waitForJs(win, source, timeout = 15000) {
+ return waitUntil(async () => {
+ if (win.isDestroyed()) throw new Error('阅读器窗口已关闭');
+ return js(win, source);
+ }, timeout, 120);
+}
+
+async function openReader(entryId, fileIndex) {
+ const win = new BrowserWindow({
+ show: false,
+ width: 1280,
+ height: 900,
+ webPreferences: {
+ preload: path.join(ROOT, 'preload.js'),
+ contextIsolation: true,
+ nodeIntegration: false
+ }
+ });
+ const errors = [];
+ win.webContents.on('console-message', (event) => {
+ const { level, message } = event;
+ if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
+ errors.push(message);
+ console.error('RENDERER:', message);
+ }
+ });
+ await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), {
+ query: { entryId, fileIndex: String(fileIndex) }
+ });
+ return { win, errors };
+}
+
+async function selectPdfText(win, page) {
+ return js(win, `(() => {
+ const root = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-text');
+ const span = root && Array.from(root.querySelectorAll('span')).find((item) => {
+ return item.firstChild && item.firstChild.nodeType === Node.TEXT_NODE
+ && item.firstChild.data.trim().length >= 12;
+ });
+ if (!span) return null;
+ const node = span.firstChild;
+ const start = Math.min(5, node.data.length - 2);
+ const end = Math.min(node.data.length, start + 24);
+ const range = document.createRange();
+ range.setStart(node, start);
+ range.setEnd(node, end);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ const rect = range.getBoundingClientRect();
+ document.dispatchEvent(new MouseEvent('mouseup', {
+ bubbles: true,
+ clientX: rect.left + Math.max(1, rect.width / 2),
+ clientY: rect.top + Math.max(1, rect.height / 2)
+ }));
+ return { text: selection.toString(), offset: start };
+ })()`);
+}
+
+async function drawPdfTouchStroke(win, page) {
+ return js(win, `(async () => {
+ const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
+ const view = canvas.ownerDocument.defaultView;
+ const rect = canvas.getBoundingClientRect();
+ const point = (id, x, y) => {
+ const init = {
+ identifier: id, target: canvas,
+ clientX: rect.left + x, clientY: rect.top + y,
+ pageX: rect.left + x + view.scrollX, pageY: rect.top + y + view.scrollY,
+ screenX: rect.left + x, screenY: rect.top + y,
+ radiusX: 2, radiusY: 2, rotationAngle: 0, force: 0.5
+ };
+ return typeof view.Touch === 'function' ? new view.Touch(init) : init;
+ };
+ const fire = (type, touches, changed) => {
+ let event;
+ if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
+ event = new view.TouchEvent(type, {
+ bubbles: true, cancelable: true, composed: true,
+ touches, targetTouches: touches, changedTouches: changed, view
+ });
+ } else {
+ // Some headless Chromium builds omit Touch; preserve the real touch event path with explicit lists.
+ event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
+ Object.defineProperties(event, {
+ touches: { value: touches },
+ targetTouches: { value: touches },
+ changedTouches: { value: changed }
+ });
+ }
+ canvas.dispatchEvent(event);
+ };
+ let touch = point(31, 90, 150);
+ fire('touchstart', [touch], [touch]);
+ for (const [x, y] of [[125, 165], [170, 180], [220, 205]]) {
+ touch = point(31, x, y);
+ fire('touchmove', [touch], [touch]);
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
+ fire('touchend', [], [touch]);
+ return typeof view.Touch === 'function' ? 'native' : 'fallback';
+ })()`);
+}
+
+async function pinchPdf(win, page) {
+ return js(win, `(async () => {
+ const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
+ const view = canvas.ownerDocument.defaultView;
+ const rect = canvas.getBoundingClientRect();
+ const cx = rect.left + rect.width * 0.5;
+ const cy = rect.top + rect.height * 0.35;
+ const point = (id, x, y) => {
+ const init = {
+ identifier: id, target: canvas,
+ clientX: x, clientY: y, pageX: x + view.scrollX, pageY: y + view.scrollY,
+ screenX: x, screenY: y, radiusX: 3, radiusY: 3, rotationAngle: 0, force: 0.5
+ };
+ return typeof view.Touch === 'function' ? new view.Touch(init) : init;
+ };
+ const fire = (type, touches, changed) => {
+ let event;
+ if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
+ event = new view.TouchEvent(type, {
+ bubbles: true, cancelable: true, composed: true,
+ touches, targetTouches: touches, changedTouches: changed, view
+ });
+ } else {
+ event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
+ Object.defineProperties(event, {
+ touches: { value: touches },
+ targetTouches: { value: touches },
+ changedTouches: { value: changed }
+ });
+ }
+ canvas.dispatchEvent(event);
+ };
+ let a = point(41, cx - 60, cy);
+ let b = point(42, cx + 60, cy);
+ fire('touchstart', [a, b], [a, b]);
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ a = point(41, cx - 105, cy);
+ b = point(42, cx + 105, cy);
+ fire('touchmove', [a, b], [a, b]);
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ fire('touchend', [], [a, b]);
+ return typeof view.Touch === 'function' ? 'native' : 'fallback';
+ })()`);
+}
+
+async function rollbackPdfStroke(win, page) {
+ return js(win, `(async () => {
+ const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
+ const view = canvas.ownerDocument.defaultView;
+ const rect = canvas.getBoundingClientRect();
+ const point = (id, x, y) => {
+ const init = {
+ identifier: id, target: canvas,
+ clientX: rect.left + x, clientY: rect.top + y,
+ pageX: rect.left + x + view.scrollX, pageY: rect.top + y + view.scrollY,
+ screenX: rect.left + x, screenY: rect.top + y,
+ radiusX: 2, radiusY: 2, rotationAngle: 0, force: 0.5
+ };
+ return typeof view.Touch === 'function' ? new view.Touch(init) : init;
+ };
+ const fire = (type, touches, changed) => {
+ let event;
+ if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
+ event = new view.TouchEvent(type, {
+ bubbles: true, cancelable: true, composed: true,
+ touches, targetTouches: touches, changedTouches: changed, view
+ });
+ } else {
+ event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
+ Object.defineProperties(event, {
+ touches: { value: touches },
+ targetTouches: { value: touches },
+ changedTouches: { value: changed }
+ });
+ }
+ canvas.dispatchEvent(event);
+ };
+ let first = point(51, 120, 260);
+ fire('touchstart', [first], [first]);
+ first = point(51, 180, 285);
+ fire('touchmove', [first], [first]);
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ const second = point(52, 300, 285);
+ fire('touchstart', [first, second], [second]);
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ fire('touchend', [], [first, second]);
+ })()`);
+}
+
+async function pinchEpub(win) {
+ return js(win, `(async () => {
+ const frame = document.querySelector('.host-epub iframe');
+ const doc = frame.contentDocument;
+ const view = frame.contentWindow;
+ const outer = document.querySelector('.epub-scroll');
+ const outerRect = outer.getBoundingClientRect();
+ const frameRect = frame.getBoundingClientRect();
+ const focalX = 260 + outerRect.left - frameRect.left;
+ const focalY = 190 + outerRect.top - frameRect.top;
+ const target = doc.elementFromPoint(focalX, focalY) || doc.body;
+ const textOffsetAtPoint = () => {
+ let point = null;
+ if (typeof doc.caretPositionFromPoint === 'function') {
+ const caret = doc.caretPositionFromPoint(focalX, focalY);
+ if (caret) point = { node: caret.offsetNode, index: caret.offset };
+ } else if (typeof doc.caretRangeFromPoint === 'function') {
+ const range = doc.caretRangeFromPoint(focalX, focalY);
+ if (range) point = { node: range.startContainer, index: range.startOffset };
+ }
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
+ let offset = 0;
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ if (point && node === point.node) return offset + Math.max(0, point.index || 0);
+ if (point && point.node && point.node.nodeType === Node.ELEMENT_NODE && point.node.contains(node)) {
+ return offset;
+ }
+ offset += node.data.length;
+ }
+ return 0;
+ };
+ const point = (id, x, y) => {
+ const init = {
+ identifier: id, target,
+ clientX: x, clientY: y, pageX: x + view.scrollX, pageY: y + view.scrollY,
+ screenX: x, screenY: y, radiusX: 3, radiusY: 3, rotationAngle: 0, force: 0.5
+ };
+ return typeof view.Touch === 'function' ? new view.Touch(init) : init;
+ };
+ const fire = (type, touches, changed) => {
+ let event;
+ if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
+ event = new view.TouchEvent(type, {
+ bubbles: true, cancelable: true, composed: true,
+ touches, targetTouches: touches, changedTouches: changed, view
+ });
+ } else {
+ event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
+ Object.defineProperties(event, {
+ touches: { value: touches },
+ targetTouches: { value: touches },
+ changedTouches: { value: changed }
+ });
+ }
+ target.dispatchEvent(event);
+ };
+ const anchorOffset = textOffsetAtPoint();
+ const before = parseFloat(view.getComputedStyle(doc.body).fontSize);
+ let a = point(61, focalX - 60, focalY);
+ let b = point(62, focalX + 60, focalY);
+ fire('touchstart', [a, b], [a, b]);
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ a = point(61, focalX - 90, focalY);
+ b = point(62, focalX + 90, focalY);
+ fire('touchmove', [a, b], [a, b]);
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ fire('touchend', [], [a, b]);
+ return {
+ anchorOffset,
+ before,
+ mode: typeof view.Touch === 'function' ? 'native' : 'fallback'
+ };
+ })()`);
+}
+
+async function selectEpubText(win, value = 'EPUB focal anchor sentence') {
+ const needle = JSON.stringify(value);
+ return js(win, `(() => {
+ const frame = document.querySelector('.host-epub iframe');
+ const doc = frame.contentDocument;
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
+ let node = walker.nextNode();
+ while (node && !node.data.includes(${needle})) node = walker.nextNode();
+ if (!node) return null;
+ const start = node.data.indexOf(${needle});
+ const end = start + ${needle}.length;
+ const range = doc.createRange();
+ range.setStart(node, start);
+ range.setEnd(node, end);
+ const selection = doc.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ const rect = range.getBoundingClientRect();
+ doc.dispatchEvent(new frame.contentWindow.MouseEvent('mouseup', {
+ bubbles: true,
+ clientX: rect.left + Math.max(1, rect.width / 2),
+ clientY: rect.top + Math.max(1, rect.height / 2)
+ }));
+ return { text: selection.toString(), offset: start };
+ })()`);
+}
+
+function printSummary() {
+ console.log('\n========== 阅读功能 Electron 集成验证 ==========');
+ for (const [status, name, detail] of results) {
+ console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
+ }
+ const failed = results.filter((result) => result[0] === 'FAIL').length;
+ console.log(`\n通过 ${results.length - failed}/${results.length}`);
+ return failed;
+}
+
+app.whenReady().then(async () => {
+ makePdf(PDF_FILE);
+ await makeEpub(EPUB_FILE);
+ makeMobi(MOBI_FILE);
+ fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
+ fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
+ fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
+ const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
+ drmFixture.writeUInt16BE(1, 96 + 12);
+ fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
+ require(path.join(ROOT, 'main.js'));
+
+ const settings = require(path.join(ROOT, 'src', 'settings'));
+ const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
+ const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
+ const rangeSessions = require(path.join(ROOT, 'src', 'reader', 'range-sessions'));
+ const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
+ const managedReader = require(path.join(ROOT, 'src', 'reader', 'window'));
+ const library = require(path.join(ROOT, 'src', 'library', 'store'));
+ settings.init(TMP);
+ readerStore.init(TMP);
+ annotations.init(TMP);
+ aiConfig.init(TMP, safeStorage);
+ aiConfig.save({
+ protocol: 'chat-completions',
+ baseUrl: 'http://127.0.0.1:65534/v1',
+ model: 'vision-fixture',
+ apiKey: '',
+ vision: true
+ });
+ library.init(path.join(TMP, 'library'));
+ require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
+
+ const entry = library.add({
+ title: 'Reader Feature Fixtures',
+ authors: ['Integration Test'],
+ files: [
+ { path: PDF_FILE, name: 'reader-features.pdf', format: 'PDF' },
+ { path: EPUB_FILE, name: 'reader-features.epub', format: 'EPUB' },
+ { path: MOBI_FILE, name: 'reader-features.mobi', format: 'MOBI' },
+ { path: DRM_MOBI_FILE, name: 'reader-features-drm.azw', format: 'AZW' },
+ { path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' }
+ ]
+ });
+ await wait(1200);
+ for (const window of BrowserWindow.getAllWindows()) window.hide();
+
+ check('本地 PDF、EPUB 和 MOBI 固定夹具已创建',
+ fs.existsSync(PDF_FILE)
+ && fs.existsSync(EPUB_FILE)
+ && fs.existsSync(MOBI_FILE)
+ && fs.existsSync(DRM_MOBI_FILE)
+ && fs.statSync(LARGE_EPUB_FILE).size === 256 * 1024 * 1024 + 1);
+
+ const pdfReader = await openReader(entry.id, 0);
+ const pdfWin = pdfReader.win;
+ await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas')?.width > 0`);
+ check('PDF 通过真实主进程 IPC 渲染', await js(pdfWin,
+ `document.querySelectorAll('.pdfx-page').length === 3`));
+ const rangeStatus = rangeSessions.status();
+ check('PDF 通过发送者隔离的分段会话读取',
+ rangeStatus.sessions === 1 && rangeStatus.bytesRead > 0,
+ `${rangeStatus.bytesRead} bytes`);
+ const failedRange = await js(pdfWin, `(async () => {
+ const module = await import('./reader/pdf-adapter.mjs');
+ const adapter = module.createPdfAdapter();
+ let closed = false;
+ try {
+ await Promise.race([
+ adapter.load({
+ kind: 'range',
+ size: 4096,
+ chunkSize: 1024,
+ read: async () => { throw new Error('range fixture failure'); },
+ close: async () => { closed = true; }
+ }),
+ new Promise((resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
+ ]);
+ return { rejected: false, closed };
+ } catch (error) {
+ return { rejected: true, message: error.message, closed };
+ } finally {
+ adapter.destroy();
+ }
+ })()`);
+ check('PDF 分段读取失败会立即拒绝加载而不是永久等待',
+ failedRange.rejected && failedRange.closed
+ && failedRange.message.includes('range fixture failure'),
+ JSON.stringify(failedRange));
+ check('右侧面板包含书签、标注、笔记和 AI 标签', await js(pdfWin, `(
+ Array.from(document.querySelectorAll('.pane-tab')).map((button) => button.dataset.pane).join(',')
+ === 'bookmarks,annotations,notes,ai'
+ )`));
+ await waitForJs(pdfWin, `(() => {
+ const controls = document.getElementById('pdfViewControls');
+ return !controls.classList.contains('hidden')
+ && document.getElementById('posLabel').textContent === '第 1 页'
+ && document.getElementById('pdfViewMode').value === 'continuous'
+ && document.getElementById('pdfPageLayout').value === 'single'
+ && document.querySelector('.pdfx-scroller').classList.contains('pdfx-view-continuous')
+ && document.querySelector('.pdfx-pages').classList.contains('pdfx-layout-single');
+ })()`);
+ check('PDF 底栏默认显示连续和单页两个独立选项', true);
+
+ pdfWin.setSize(760, 700);
+ await wait(150);
+ check('窄窗口仍保留 PDF 阅读和版式控件', await js(pdfWin, `(() => {
+ const bar = document.querySelector('.statusbar');
+ const controls = document.getElementById('pdfViewControls');
+ return !controls.classList.contains('hidden') && bar.scrollWidth <= bar.clientWidth;
+ })()`));
+
+ pdfWin.setSize(2200, 1000);
+ await js(pdfWin, `(() => {
+ const select = document.getElementById('pdfPageLayout');
+ select.value = 'auto';
+ select.dispatchEvent(new Event('change'));
+ })()`);
+ await waitForJs(pdfWin, `(() => {
+ const pages = document.querySelectorAll('.pdfx-page');
+ return document.querySelector('.pdfx-pages').classList.contains('pdfx-layout-auto')
+ && pages[0].offsetTop === pages[1].offsetTop;
+ })()`);
+ check('自动版式在宽窗口并排显示多页', await js(pdfWin, `(() => {
+ const pages = document.querySelectorAll('.pdfx-page');
+ return pages[0].offsetTop === pages[1].offsetTop
+ && pages[0].offsetLeft !== pages[1].offsetLeft;
+ })()`));
+
+ await js(pdfWin, `(() => {
+ const select = document.getElementById('pdfViewMode');
+ select.value = 'paged';
+ select.dispatchEvent(new Event('change'));
+ })()`);
+ await waitForJs(pdfWin, `getComputedStyle(document.querySelector('.pdfx-scroller'))
+ .scrollSnapType.includes('mandatory')`);
+ check('分页模式启用按行滚动吸附', await js(pdfWin, `(() => {
+ const scroller = document.querySelector('.pdfx-scroller');
+ const pages = document.querySelectorAll('.pdfx-page');
+ return scroller.classList.contains('pdfx-view-paged')
+ && pages[0].offsetTop === pages[1].offsetTop
+ && pages[2].offsetTop > pages[0].offsetTop;
+ })()`));
+ await waitUntil(() => Promise.resolve(
+ settings.get('reader.pdfViewMode', '') === 'paged'
+ && settings.get('reader.pdfPageLayout', '') === 'auto'
+ ));
+ check('PDF 阅读和版式偏好已持久化', true);
+ await js(pdfWin, `document.getElementById('nextBtn').click()`);
+ await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
+ check('自动多页的分页导航按整行前进', true);
+
+ await js(pdfWin, `(() => {
+ const mode = document.getElementById('pdfViewMode');
+ mode.value = 'continuous';
+ mode.dispatchEvent(new Event('change'));
+ const layout = document.getElementById('pdfPageLayout');
+ layout.value = 'single';
+ layout.dispatchEvent(new Event('change'));
+ })()`);
+ pdfWin.setSize(1280, 900);
+ await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
+ .classList.contains('pdfx-layout-single')`);
+ try {
+ await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
+ .some((node) => node.firstChild && node.firstChild.data.trim())`);
+ } catch (error) {
+ const state = await js(pdfWin, `(() => ({
+ position: document.getElementById('posLabel').textContent,
+ scrollTop: document.querySelector('.pdfx-scroller').scrollTop,
+ pages: Array.from(document.querySelectorAll('.pdfx-page')).map((page) => ({
+ page: page.dataset.page,
+ top: page.offsetTop,
+ canvas: page.querySelector('.pdfx-canvas')?.width || 0,
+ spans: page.querySelectorAll('.pdfx-text span').length
+ }))
+ }))()`);
+ throw new Error(`${error.message}; ${JSON.stringify(state)}`);
+ }
+
+ const selectionClearedByZoom = await js(pdfWin, `(() => {
+ const span = Array.from(document.querySelectorAll('.pdfx-text span'))
+ .find((node) => node.firstChild && node.firstChild.data.trim());
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(span);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ const button = document.getElementById('zoomOutBtn');
+ button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
+ button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
+ button.click();
+ return !selection.toString()
+ && document.getElementById('selBar').classList.contains('hidden');
+ })()`);
+ check('点击缩放等阅读器控件会清除正文选区且不重新触发划选工具条',
+ selectionClearedByZoom);
+ await waitForJs(pdfWin,
+ `document.querySelector('.pdfx-page[data-page="3"] .pdfx-canvas')?.width > 0`);
+
+ const fitPage = await js(pdfWin, `document.getElementById('posLabel').textContent`);
+ pdfWin.setSize(900, 700);
+ await wait(180);
+ await js(pdfWin, `document.getElementById('fitWidthBtn').click()`);
+ await waitForJs(pdfWin, `(() => {
+ const page = document.querySelector('.pdfx-page[data-page="3"]');
+ const scroller = document.querySelector('.pdfx-scroller');
+ return page && page.querySelector('.pdfx-canvas').width > 0
+ && Math.abs(page.getBoundingClientRect().width - (scroller.clientWidth - 32)) <= 2;
+ })()`, 20000);
+ const narrowFit = await js(pdfWin, `(() => {
+ const page = document.querySelector('.pdfx-page[data-page="3"]');
+ const scroller = document.querySelector('.pdfx-scroller');
+ return {
+ zoom: Number.parseFloat(document.getElementById('zoomLabel').textContent),
+ pageWidth: page.getBoundingClientRect().width,
+ available: scroller.clientWidth - 32,
+ position: document.getElementById('posLabel').textContent
+ };
+ })()`);
+ check('PDF 适应内容宽度在窄窗口贴合可用宽度并保持当前页',
+ Math.abs(narrowFit.pageWidth - narrowFit.available) <= 2
+ && narrowFit.position === fitPage,
+ JSON.stringify(narrowFit));
+ await js(pdfWin, `document.getElementById('zoomInBtn').click()`);
+ await waitForJs(pdfWin,
+ `Number.parseFloat(document.getElementById('zoomLabel').textContent) > ${narrowFit.zoom}`);
+ const manualZoom = await js(pdfWin,
+ `Number.parseFloat(document.getElementById('zoomLabel').textContent)`);
+ await js(pdfWin, `document.getElementById('zoomOutBtn').click()`);
+ await waitForJs(pdfWin,
+ `Number.parseFloat(document.getElementById('zoomLabel').textContent) < ${manualZoom}`);
+ check('适宽产生的非预设比例之后仍可正常手动放大和缩小', true);
+
+ await js(pdfWin, `(() => {
+ const layout = document.getElementById('pdfPageLayout');
+ layout.value = 'auto';
+ layout.dispatchEvent(new Event('change'));
+ })()`);
+ pdfWin.setSize(1900, 900);
+ await wait(220);
+ await js(pdfWin, `document.getElementById('fitWidthBtn').click()`);
+ await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="3"] .pdfx-canvas')?.width > 0`,
+ 20000);
+ check('自动多页版式适宽时整行内容保持在可用宽度内', await js(pdfWin, `(() => {
+ const scroller = document.querySelector('.pdfx-scroller');
+ const rows = new Map();
+ for (const page of document.querySelectorAll('.pdfx-page')) {
+ const top = page.offsetTop;
+ const row = rows.get(top) || [];
+ row.push(page);
+ rows.set(top, row);
+ }
+ return [...rows.values()].every((row) => {
+ const first = row[0].getBoundingClientRect();
+ const last = row[row.length - 1].getBoundingClientRect();
+ return last.right - first.left <= scroller.clientWidth - 30;
+ }) && document.getElementById('posLabel').textContent === ${JSON.stringify(fitPage)};
+ })()`));
+ await js(pdfWin, `(() => {
+ const layout = document.getElementById('pdfPageLayout');
+ layout.value = 'single';
+ layout.dispatchEvent(new Event('change'));
+ })()`);
+ pdfWin.setSize(1280, 900);
+ await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
+ .classList.contains('pdfx-layout-single')`);
+
+ await js(pdfWin, `(() => {
+ const range = document.getElementById('progressRange');
+ range.value = '500';
+ range.dispatchEvent(new Event('change'));
+ })()`);
+ await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 2 页'`);
+ await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="2"] .pdfx-text span')?.textContent.length > 0`);
+
+ await js(pdfWin, `document.querySelector('[data-pane="ai"]').click();
+ var s=document.getElementById('aiScope');
+ s.value='page-image';
+ s.dispatchEvent(new Event('change'));`);
+ await waitForJs(pdfWin, `(() => {
+ const card = document.getElementById('aiVisualCard');
+ const image = document.getElementById('aiVisualPreview');
+ return !card.classList.contains('hidden') && image.complete && image.naturalWidth > 0;
+ })()`, 20000);
+ const fullPageVisual = await js(pdfWin, `(() => {
+ const image = document.getElementById('aiVisualPreview');
+ return {
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ label: document.getElementById('aiVisualMeta').textContent
+ };
+ })()`);
+ const fullPageDataUrl = await js(pdfWin, `document.getElementById('aiVisualPreview').src`);
+ check('PDF 当前页按独立高分辨率生成图像上下文',
+ fullPageVisual.width > 1000
+ && Math.max(fullPageVisual.width, fullPageVisual.height) <= 1600
+ && fullPageVisual.label.includes('第 2 页')
+ && imageHasInk(fullPageDataUrl),
+ JSON.stringify(fullPageVisual));
+
+ await js(pdfWin, `var s=document.getElementById('aiScope');
+ s.value='region-image';
+ s.dispatchEvent(new Event('change'));`);
+ await waitForJs(pdfWin, `!!document.querySelector('.visual-select-overlay')`);
+ const pdfRegionSelected = await js(pdfWin, `(() => {
+ const overlay = document.querySelector('.visual-select-overlay');
+ const page = document.querySelector('.pdfx-page[data-page="2"]').getBoundingClientRect();
+ const x1 = page.left + 60;
+ const y1 = page.top + 70;
+ const x2 = Math.min(page.right - 40, x1 + 260);
+ const y2 = Math.min(page.bottom - 40, y1 + 190);
+ overlay.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 52, button: 0, buttons: 1, clientX: x1, clientY: y1 }));
+ overlay.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerId: 52, buttons: 1, clientX: x2, clientY: y2 }));
+ overlay.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 52, button: 0, clientX: x2, clientY: y2 }));
+ return !overlay.querySelector('.visual-select-actions').classList.contains('hidden');
+ })()`);
+ check('PDF 框选被限制在单个页面并进入确认状态', pdfRegionSelected);
+ await js(pdfWin, `document.querySelector('.visual-select-actions .tb-btn').click()`);
+ await waitForJs(pdfWin, `(() => {
+ const image = document.getElementById('aiVisualPreview');
+ return !document.querySelector('.visual-select-overlay')
+ && document.getElementById('aiVisualLabel').textContent === '框选区域'
+ && image.complete && image.naturalWidth > 0;
+ })()`, 20000);
+ const regionSize = await js(pdfWin, `(() => {
+ const image = document.getElementById('aiVisualPreview');
+ return { width: image.naturalWidth, height: image.naturalHeight };
+ })()`);
+ const regionDataUrl = await js(pdfWin, `document.getElementById('aiVisualPreview').src`);
+ // 局部裁剪会放大到发送上限,所以比的是长宽比而不是绝对像素:
+ // 区域比整页更“扁”,说明裁的确实是页面的一小块
+ check('PDF 框选区域按页面坐标高质量裁剪',
+ regionSize.width > 100
+ && regionSize.height > 100
+ && Math.max(regionSize.width, regionSize.height) <= 1600
+ && regionSize.width / regionSize.height > fullPageVisual.width / fullPageVisual.height
+ && imageHasInk(regionDataUrl),
+ JSON.stringify({ region: regionSize, page: fullPageVisual }));
+ await js(pdfWin, `document.getElementById('aiVisualRemoveBtn').click()`);
+
+ await js(pdfWin, `document.querySelector('[data-pane="notes"]').click();
+ document.getElementById('addNoteBtn').click()`);
+ check('阅读器新建笔记先选择读书笔记或画布笔记', await js(pdfWin,
+ `!document.getElementById('noteTypeChooser').classList.contains('hidden')
+ && Array.from(document.querySelectorAll('#noteTypeChooser [data-note-type]'))
+ .map((button) => button.textContent.trim()).join('|').includes('读书笔记')
+ && Array.from(document.querySelectorAll('#noteTypeChooser [data-note-type]'))
+ .map((button) => button.textContent.trim()).join('|').includes('画布笔记')`));
+ await js(pdfWin, `document.querySelector('#noteTypeChooser [data-note-type="reading"]').click()`);
+ check('人工笔记编辑框可由笔记面板打开', await js(pdfWin,
+ `!document.getElementById('noteEditorModal').classList.contains('hidden')
+ && document.getElementById('noteAssociation').textContent
+ === '关联当前书籍:Reader Feature Fixtures'`));
+ await js(pdfWin, `(() => {
+ document.getElementById('noteTitleInput').value = 'Manual reader note';
+ Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
+ .setText('Manual note body saved through IPC.');
+ document.getElementById('noteTagsInput').value = 'touch, integration,touch';
+ document.getElementById('noteEditorSaveBtn').click();
+ })()`);
+ await waitUntil(() => readerStore.getState(entry.id).notes.length === 1);
+ const manual = readerStore.getState(entry.id).notes[0];
+ check('人工笔记标题、正文、标签和来源写入 readerStore',
+ manual.title === 'Manual reader note'
+ && manual.noteType === 'reading'
+ && manual.text === 'Manual note body saved through IPC.'
+ && manual.source === 'manual'
+ && JSON.stringify(manual.tags) === JSON.stringify(['touch', 'integration']));
+ check('人工笔记保存 PDF 文件来源和第 2 页位置',
+ manual.fileIndex === 0
+ && manual.documentKey === annotations.documentKey(PDF_FILE)
+ && manual.locator && manual.locator.kind === 'pdf' && manual.locator.page === 2);
+ await waitForJs(pdfWin,
+ `document.getElementById('noteList').textContent.includes('Manual reader note')
+ && document.getElementById('noteList').textContent.includes('#touch')`);
+ check('人工笔记立即显示在笔记面板', await js(pdfWin,
+ `document.getElementById('noteList').textContent.includes('Manual reader note')
+ && document.getElementById('noteList').textContent.includes('#touch')`));
+
+ await js(pdfWin, `document.getElementById('addNoteBtn').click();
+ document.querySelector('#noteTypeChooser [data-note-type="canvas"]').click()`);
+ await waitForJs(pdfWin, `!!document.querySelector('#noteRichEditor .canvas-note-root')`, 15000);
+ check('阅读器画布仅工作区滚动且工具栏使用一致的图标按钮', await js(pdfWin, `(() => {
+ const viewport = document.querySelector('#noteRichEditor .canvas-note-viewport');
+ const toolbar = document.querySelector('#noteRichEditor .canvas-note-toolbar');
+ const buttons = [...toolbar.querySelectorAll('.canvas-note-button')];
+ const scrollables = [];
+ for (let node = viewport; node && node.id !== 'noteEditorModal'; node = node.parentElement) {
+ const style = getComputedStyle(node);
+ if (/auto|scroll/.test(style.overflowX) || /auto|scroll/.test(style.overflowY)) {
+ scrollables.push(node);
+ }
+ }
+ return scrollables.length === 1
+ && scrollables[0] === viewport
+ && getComputedStyle(document.getElementById('noteEditorFields')).overflowY === 'hidden'
+ && getComputedStyle(toolbar).flexWrap === 'wrap'
+ && getComputedStyle(toolbar).overflowX === 'visible'
+ && buttons.length >= 10
+ && buttons.every((button) => !!button.querySelector('.canvas-note-icon'))
+ && viewport.clientHeight > 0;
+ })()`));
+ await js(pdfWin, `(() => {
+ document.querySelector('#noteRichEditor [data-tool="flow-text"]').click();
+ const quill = Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill'));
+ quill.setText('阅读器全局正文\\n第二段会和手写批注一起保存。\\n', 'user');
+ })()`);
+ check('阅读器全局文本工具显示富文本格式栏', await js(pdfWin,
+ `!document.querySelector('#noteRichEditor .canvas-flow-toolbar-host')
+ .classList.contains('hidden')
+ && document.querySelector('#noteRichEditor .canvas-flow-layer')
+ .classList.contains('canvas-flow-active')`));
+ const originalPdfPicker = dialog.showOpenDialog;
+ dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [PDF_FILE] });
+ await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-import-pdf').click()`);
+ await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-template').value === '__pdf'
+ && document.querySelector('#noteRichEditor .canvas-note-background').width > 0`, 20000);
+ dialog.showOpenDialog = originalPdfPicker;
+ const readerFlowTextBeforeDelete = await js(pdfWin,
+ `Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill')).getText()`);
+ await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-delete-page').click()`);
+ check('含 PDF 底版的页面需要二次确认才删除', await js(pdfWin,
+ `document.querySelector('#noteRichEditor .canvas-note-delete-page')
+ .classList.contains('canvas-note-delete-confirm')
+ && document.querySelector('#noteRichEditor .canvas-note-page-counter')
+ .textContent.endsWith('/ 3')`));
+ await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-delete-page').click()`);
+ await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-page-counter')
+ .textContent.endsWith('/ 2')`);
+ check('确认删除 PDF 页面后全局正文保持不变', await js(pdfWin,
+ `Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill')).getText()
+ === ${JSON.stringify(readerFlowTextBeforeDelete)}`));
+ await js(pdfWin, `(() => {
+ document.getElementById('noteTitleInput').value = 'Reader canvas note';
+ document.querySelector('#noteRichEditor [data-tool="pen"]').click();
+ const canvas = document.querySelector('#noteRichEditor .upper-canvas');
+ const rect = canvas.getBoundingClientRect();
+ const event = (type, x, y, buttons) => new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ clientX: rect.left + x,
+ clientY: rect.top + y,
+ button: 0,
+ buttons
+ });
+ canvas.dispatchEvent(event('mousedown', 80, 100, 1));
+ document.dispatchEvent(event('mousemove', 140, 130, 1));
+ document.dispatchEvent(event('mouseup', 190, 150, 0));
+ document.getElementById('noteEditorSaveBtn').click();
+ })()`);
+ await waitUntil(() => readerStore.getState(entry.id).notes.length === 2);
+ const canvasNote = readerStore.getState(entry.id).notes.find((note) => (
+ note.title === 'Reader canvas note'
+ ));
+ check('阅读器画布笔记保存 PDF 底版和自由画笔并提交受管资源',
+ canvasNote?.noteType === 'canvas'
+ && canvasNote.canvasContent?.pages.length === 2
+ && canvasNote.canvasContent.flow?.ops.some((op) => (
+ typeof op.insert === 'string' && op.insert.includes('阅读器全局正文')
+ ))
+ && canvasNote.canvasContent.pages.every((page) => /^pdf_[a-f0-9]{64}$/.test(
+ page.background.assetId || ''
+ ))
+ && canvasNote.canvasContent.pages[0].objects.some((object) => (
+ object.type === 'Path' && object.canvasKind === 'pen'
+ )));
+ check('阅读器笔记列表区分读书笔记和画布笔记', await js(pdfWin,
+ `document.getElementById('noteList').textContent.includes('读书笔记')
+ && document.getElementById('noteList').textContent.includes('画布笔记')`));
+ await js(pdfWin, `(() => {
+ const item = Array.from(document.querySelectorAll('#noteList .list-item'))
+ .find((entry) => entry.textContent.includes('Reader canvas note'));
+ item.querySelector('.list-item-edit').click();
+ })()`);
+ await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-template').value === '__pdf'
+ && document.querySelector('#noteRichEditor .canvas-note-background').width > 0`, 20000);
+ check('重开画布笔记恢复全局正文', await js(pdfWin,
+ `Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill'))
+ .getText().includes('阅读器全局正文')`));
+ await js(pdfWin, `document.getElementById('noteEditorSaveBtn').click()`);
+ await waitForJs(pdfWin, `document.getElementById('noteEditorModal').classList.contains('hidden')`);
+ const reopenedCanvas = readerStore.getState(entry.id).notes.find((note) => (
+ note.title === 'Reader canvas note'
+ ));
+ check('重开并保存画布笔记后保留 PDF 底版和 Fabric 画笔',
+ reopenedCanvas?.canvasContent?.pages.length === 2
+ && reopenedCanvas.canvasContent.flow?.ops.some((op) => (
+ typeof op.insert === 'string' && op.insert.includes('阅读器全局正文')
+ ))
+ && reopenedCanvas.canvasContent.pages[0].objects.some((object) => (
+ object.type === 'Path' && object.canvasKind === 'pen'
+ )));
+
+ const crossLineSelection = await js(pdfWin, `new Promise((resolve) => {
+ const layer = document.querySelector('.pdfx-page[data-page="2"] .pdfx-text');
+ const spans = Array.from(layer.querySelectorAll('span')).filter((span) => (
+ span.firstChild && span.firstChild.nodeType === Node.TEXT_NODE
+ && span.firstChild.data.trim().length >= 12
+ ));
+ const range = document.createRange();
+ range.setStart(spans[0].firstChild, 5);
+ range.setEnd(spans[1].firstChild, Math.min(24, spans[1].firstChild.data.length));
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ requestAnimationFrame(() => {
+ const pageRect = layer.getBoundingClientRect();
+ const rects = Array.from(range.getClientRects()).filter((rect) => rect.width && rect.height);
+ const result = {
+ sentinelCount: layer.querySelectorAll('.endOfContent').length,
+ selecting: layer.classList.contains('selecting'),
+ text: selection.toString(),
+ bounded: rects.length >= 2 && rects.every((rect) => (
+ rect.left >= pageRect.left - 1
+ && rect.right <= pageRect.right + 1
+ && rect.top >= pageRect.top - 1
+ && rect.bottom <= pageRect.bottom + 1
+ && rect.height < pageRect.height / 4
+ ))
+ };
+ selection.removeAllRanges();
+ resolve(result);
+ });
+ })`);
+ check('PDF 跨行选择只覆盖实际文字区域',
+ crossLineSelection.sentinelCount === 1
+ && crossLineSelection.selecting
+ && crossLineSelection.text.includes('selectable text validates')
+ && crossLineSelection.text.includes('Touch gestures')
+ && crossLineSelection.bounded,
+ JSON.stringify(crossLineSelection));
+
+ let excerptSelection = null;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ excerptSelection = await selectPdfText(pdfWin, 2);
+ await wait(150);
+ if (await js(pdfWin, `!document.getElementById('selBar').classList.contains('hidden')`)) break;
+ }
+ await waitForJs(pdfWin, `!document.getElementById('selBar').classList.contains('hidden')`);
+ check('PDF 文本选择显示摘录和记笔记操作',
+ !!excerptSelection && await js(pdfWin, `(
+ !!document.querySelector('[data-sel="excerpt"]')
+ && !!document.querySelector('[data-sel="note"]')
+ )`), excerptSelection && excerptSelection.text);
+ await js(pdfWin, `document.querySelector('[data-sel="excerpt"]').click()`);
+ await waitUntil(() => readerStore.getState(entry.id).notes.length === 3);
+ const excerpt = readerStore.getState(entry.id).notes.find((note) => (
+ note.source === 'selection' && !note.text
+ ));
+ check('摘录操作保存引文、上下文和 PDF 位置',
+ !!excerpt
+ && excerpt.quote === excerptSelection.text
+ && excerpt.context.includes(excerptSelection.text.trim())
+ && excerpt.locator.kind === 'pdf'
+ && excerpt.locator.page === 2
+ && Number.isInteger(excerpt.locator.offset));
+ check('摘录通过真实 readerStore 保存文件来源',
+ !!excerpt
+ && excerpt.fileIndex === 0
+ && excerpt.documentKey === annotations.documentKey(PDF_FILE));
+ check('摘录立即显示在笔记面板', await js(pdfWin,
+ `document.getElementById('noteList').textContent.includes(${JSON.stringify(excerptSelection.text)})`));
+
+ const noteSelection = await selectPdfText(pdfWin, 2);
+ await js(pdfWin, `document.querySelector('[data-sel="note"]').click()`);
+ await waitForJs(pdfWin, `!document.getElementById('noteEditorModal').classList.contains('hidden')`);
+ check('记笔记操作保留选择引文预览', await js(pdfWin,
+ `document.getElementById('noteQuotePreview').textContent === ${JSON.stringify(noteSelection.text)}
+ && !document.getElementById('noteQuotePreview').classList.contains('hidden')`));
+ await js(pdfWin, `(() => {
+ document.getElementById('noteTitleInput').value = 'Selection reader note';
+ Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
+ .setText('Comment attached to the selected PDF quote.');
+ document.getElementById('noteTagsInput').value = 'selection, pdf';
+ document.getElementById('noteEditorSaveBtn').click();
+ })()`);
+ await waitUntil(() => readerStore.getState(entry.id).notes.length === 4);
+ const selectionNote = readerStore.getState(entry.id).notes.find((note) => (
+ note.title === 'Selection reader note'
+ ));
+ check('记笔记操作保存正文、引文、标签和选择来源',
+ !!selectionNote
+ && selectionNote.text === 'Comment attached to the selected PDF quote.'
+ && selectionNote.quote === noteSelection.text
+ && selectionNote.source === 'selection'
+ && JSON.stringify(selectionNote.tags) === JSON.stringify(['selection', 'pdf']));
+ check('选择笔记保存第 2 页定位', !!selectionNote
+ && selectionNote.locator.kind === 'pdf' && selectionNote.locator.page === 2);
+
+ await js(pdfWin, `document.getElementById('annotationToggleBtn').click();
+ document.querySelector('[data-annotation-tool="pen"]').click()`);
+ await waitForJs(pdfWin, `getComputedStyle(
+ document.querySelector('.pdfx-page[data-page="2"] .pdfx-annotation')
+ ).pointerEvents === 'auto'`);
+ const pdfTouchMode = await drawPdfTouchStroke(pdfWin, 2);
+ await waitForJs(pdfWin, `document.getElementById('annotationStatus').textContent.includes('1 项')`);
+ check('单指真实触摸事件可用画笔创建笔划', true, pdfTouchMode);
+ await waitUntil(() => {
+ const stored = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
+ return stored.pages['2'] && stored.pages['2'].objects.length === 1;
+ });
+ let storedAnnotations = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
+ check('触摸画笔通过真实批注 IPC 持久化',
+ storedAnnotations.pages['2'].objects[0].annotationKind === 'pen');
+
+ await js(pdfWin, `document.querySelector('[data-pane="annotations"]').click()`);
+ await waitForJs(pdfWin, `document.getElementById('annotationList').textContent.includes('第 2 页')`);
+ check('标注页列表显示页码、数量和画笔类型', await js(pdfWin,
+ `document.getElementById('annotationList').textContent.includes('第 2 页')
+ && document.getElementById('annotationList').textContent.includes('1 项标注')
+ && document.getElementById('annotationList').textContent.includes('画笔')`));
+ await js(pdfWin, `document.getElementById('nextBtn').click()`);
+ await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
+ await js(pdfWin, `document.querySelector('#annotationList .list-item-label').click()`);
+ await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 2 页'`);
+ check('标注页列表可跳回被标注页', true);
+
+ const zoomBefore = await js(pdfWin, `document.getElementById('zoomLabel').textContent`);
+ const pinchMode = await pinchPdf(pdfWin, 2);
+ await waitForJs(pdfWin, `!document.querySelector('.host-pdf').classList.contains('pinch-preview')
+ && document.getElementById('zoomLabel').textContent !== ${JSON.stringify(zoomBefore)}`, 20000);
+ const zoomAfter = await js(pdfWin, `document.getElementById('zoomLabel').textContent`);
+ check('PDF 双指触摸缩放改变比例', zoomAfter !== zoomBefore,
+ `${zoomBefore} -> ${zoomAfter}; ${pinchMode}`);
+ check('PDF 双指缩放保持焦点页', await js(pdfWin,
+ `document.getElementById('posLabel').textContent === '第 2 页'`));
+
+ await waitForJs(pdfWin, `!!document.querySelector(
+ '.pdfx-page[data-page="2"] .pdfx-annotation .upper-canvas'
+ )`, 20000);
+ await rollbackPdfStroke(pdfWin, 2);
+ await wait(1800);
+ storedAnnotations = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
+ check('画笔中途加入第二指会回滚未完成笔划',
+ storedAnnotations.pages['2'].objects.length === 1,
+ `对象=${storedAnnotations.pages['2'].objects.length}`);
+ check('回滚后界面对象计数没有增加', await js(pdfWin,
+ `document.getElementById('annotationStatus').textContent.includes('1 项')`));
+ check('PDF 阅读器没有控制台错误', pdfReader.errors.length === 0,
+ pdfReader.errors.slice(0, 3).join(' | '));
+
+ pdfWin.close();
+ await waitUntil(() => rangeSessions.status().sessions === 0, 5000);
+ check('关闭 PDF 阅读器会释放分段文件句柄', rangeSessions.status().sessions === 0);
+
+ const largeEpubReader = await openReader(entry.id, 4);
+ const largeEpubWin = largeEpubReader.win;
+ await waitForJs(largeEpubWin, `document.querySelector('.doc-overlay.err .doc-overlay-msg')
+ ?.textContent.includes('超过 256 MB')`);
+ check('超大 EPUB 在分配整文件内存前停止并提供外部应用后备', await js(largeEpubWin, `(
+ document.querySelector('.doc-overlay.err .doc-overlay-msg').textContent.includes('请使用外部应用')
+ && Array.from(document.querySelectorAll('.doc-overlay.err button'))
+ .some((button) => button.textContent === '使用系统应用打开')
+ )`));
+ largeEpubWin.close();
+ await wait(200);
+
+ const epubReader = await openReader(entry.id, 1);
+ const epubWin = epubReader.win;
+ await waitForJs(epubWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
+ ?.textContent.includes('EPUB focal anchor sentence')`, 20000);
+ check('EPUB 本地夹具通过真实主进程 IPC 渲染', await js(epubWin,
+ `document.getElementById('zoomLabel').textContent === '18px'
+ && document.getElementById('posLabel').textContent === 'Start'`));
+ check('非 PDF 阅读时隐藏 PDF 阅读和版式选项', await js(epubWin,
+ `document.getElementById('pdfViewControls').classList.contains('hidden')`));
+ const epubKey = annotations.documentKey(EPUB_FILE);
+ await js(epubWin, `Array.from(document.querySelectorAll('.toc-item'))
+ .find((item) => item.textContent.includes('Middle Section')).click()`);
+ await waitUntil(() => {
+ const progress = readerStore.getState(entry.id, epubKey).progress;
+ return progress && progress.locator && progress.locator.offset > 100;
+ }, 5000);
+ const tocOffset = readerStore.getState(entry.id, epubKey).progress.locator.offset;
+ check('EPUB 目录片段跳到章节内字符位置', tocOffset > 100, String(tocOffset));
+
+ await js(epubWin, `(() => {
+ const range = document.getElementById('progressRange');
+ range.value = '0';
+ range.dispatchEvent(new Event('change'));
+ })()`);
+ await wait(700);
+ await js(epubWin, `document.querySelector('.host-epub iframe').contentDocument
+ .querySelector('a[data-epub-href="#section-mid"]').click()`);
+ await waitUntil(() => {
+ const progress = readerStore.getState(entry.id, epubKey).progress;
+ return progress && progress.locator && Math.abs(progress.locator.offset - tocOffset) <= 4;
+ }, 5000);
+ check('EPUB 正文保留的内部链接可导航',
+ Math.abs(readerStore.getState(entry.id, epubKey).progress.locator.offset - tocOffset) <= 4);
+
+ await js(epubWin, `(() => {
+ const range = document.getElementById('progressRange');
+ range.value = '420';
+ range.dispatchEvent(new Event('change'));
+ })()`);
+ await wait(1400);
+ const pinchResult = await pinchEpub(epubWin);
+ await waitForJs(epubWin, `!document.querySelector('.host-epub').classList.contains('pinch-preview')
+ && document.getElementById('zoomLabel').textContent !== '18px'`, 20000);
+ const epubFont = await js(epubWin, `parseFloat(document.querySelector(
+ '.host-epub iframe'
+ ).contentWindow.getComputedStyle(document.querySelector(
+ '.host-epub iframe'
+ ).contentDocument.body).fontSize)`);
+ check('EPUB iframe 内双指触摸改变字号',
+ epubFont > pinchResult.before,
+ `${pinchResult.before}px -> ${epubFont}px; ${pinchResult.mode}`);
+ check('EPUB 双指缩放保持当前章节', await js(epubWin,
+ `document.getElementById('posLabel').textContent === 'Start'`));
+ await wait(1200);
+ await waitUntil(() => {
+ const progress = readerStore.getState(entry.id).progress;
+ return progress && progress.locator && progress.locator.kind === 'epub';
+ }, 5000);
+ const epubProgress = readerStore.getState(entry.id).progress;
+ const focalOffsetAfter = await js(epubWin, `(() => {
+ const frame = document.querySelector('.host-epub iframe');
+ const doc = frame.contentDocument;
+ const outerRect = document.querySelector('.epub-scroll').getBoundingClientRect();
+ const frameRect = frame.getBoundingClientRect();
+ const x = 260 + outerRect.left - frameRect.left;
+ const y = 190 + outerRect.top - frameRect.top;
+ let caret = null;
+ if (typeof doc.caretPositionFromPoint === 'function') {
+ caret = doc.caretPositionFromPoint(x, y);
+ } else if (typeof doc.caretRangeFromPoint === 'function') {
+ const range = doc.caretRangeFromPoint(x, y);
+ if (range) caret = { offsetNode: range.startContainer, offset: range.startOffset };
+ }
+ if (!caret) return -1;
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
+ let offset = 0;
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ if (node === caret.offsetNode) return offset + caret.offset;
+ offset += node.data.length;
+ }
+ return -1;
+ })()`);
+ check('EPUB 双指缩放保持焦点附近字符偏移',
+ Math.abs(focalOffsetAfter - pinchResult.anchorOffset) <= 12,
+ `${pinchResult.anchorOffset} -> ${focalOffsetAfter}; 顶部=${epubProgress.locator.offset}`);
+
+ const epubSelection = await selectEpubText(epubWin);
+ await waitForJs(epubWin, `!document.getElementById('selBar').classList.contains('hidden')`);
+ check('EPUB iframe 单指兼容选择流程显示选择工具条',
+ !!epubSelection && epubSelection.text === 'EPUB focal anchor sentence');
+ await js(epubWin, `document.querySelector('[data-sel="excerpt"]').click()`);
+ await waitUntil(() => readerStore.getState(entry.id).notes.length === 5);
+ const epubExcerpt = readerStore.getState(entry.id).notes.find((note) => (
+ note.fileIndex === 1 && note.quote === 'EPUB focal anchor sentence'
+ ));
+ check('EPUB 选择摘录通过真实 readerStore 保存',
+ !!epubExcerpt
+ && epubExcerpt.source === 'selection'
+ && epubExcerpt.documentKey === annotations.documentKey(EPUB_FILE)
+ && epubExcerpt.locator.kind === 'epub'
+ && epubExcerpt.locator.chapter === 0
+ && Number.isInteger(epubExcerpt.locator.offset));
+ check('EPUB 摘录立即显示在笔记面板', await js(epubWin,
+ `document.getElementById('noteList').textContent.includes('EPUB focal anchor sentence')`));
+ check('EPUB 阅读器没有控制台错误', epubReader.errors.length === 0,
+ epubReader.errors.slice(0, 3).join(' | '));
+
+ const mobiReader = await openReader(entry.id, 2);
+ const mobiWin = mobiReader.win;
+ await waitForJs(mobiWin, `(() => {
+ const error = document.querySelector('.doc-overlay.err .doc-overlay-msg')?.textContent;
+ if (error) throw new Error(error);
+ return document.querySelector('.host-epub iframe')?.contentDocument?.body
+ ?.textContent.includes('MOBI selectable text');
+ })()`, 30000);
+ check('MOBI 通过 Foliate 解析器在内置阅读器渲染', await js(mobiWin,
+ `document.querySelector('.doctab-fmt')?.textContent === 'mobi'
+ && document.getElementById('zoomLabel').textContent === '18px'
+ && document.getElementById('posLabel').textContent.includes('第 1 章')`));
+ check('MOBI 脚本、事件属性和外部资源在渲染前被移除', await js(mobiWin, `(() => {
+ const frame = document.querySelector('.host-epub iframe');
+ const doc = frame.contentDocument;
+ return !doc.querySelector('script, iframe, object, embed, [onerror], img[src^="http"]')
+ && !frame.contentWindow.__mobiScriptExecuted
+ && !frame.contentWindow.__mobiHandlerExecuted
+ && !frame.contentWindow.__mobiLinkExecuted;
+ })()`));
+ const mobiKey = annotations.documentKey(MOBI_FILE);
+ await js(mobiWin, `document.getElementById('addBookmarkBtn').click()`);
+ await waitUntil(() => readerStore.getState(entry.id, mobiKey).bookmarks.length === 1);
+ check('MOBI 书签保存稳定章节字符位置', (() => {
+ const bookmark = readerStore.getState(entry.id, mobiKey).bookmarks[0];
+ return bookmark
+ && bookmark.documentKey === mobiKey
+ && bookmark.locator.kind === 'mobi'
+ && bookmark.locator.chapter === 0
+ && Number.isInteger(bookmark.locator.offset);
+ })());
+ const mobiSelection = await selectEpubText(mobiWin, 'MOBI selectable text');
+ await waitForJs(mobiWin, `!document.getElementById('selBar').classList.contains('hidden')`);
+ check('MOBI 正文支持选择和摘录操作',
+ !!mobiSelection && mobiSelection.text === 'MOBI selectable text');
+ await js(mobiWin, `document.querySelector('[data-sel="excerpt"]').click()`);
+ await waitUntil(() => readerStore.getState(entry.id).notes.length === 6);
+ const mobiExcerpt = readerStore.getState(entry.id).notes.find((note) => (
+ note.fileIndex === 2 && note.quote === 'MOBI selectable text'
+ ));
+ check('MOBI 摘录关联原文件、文档指纹和定位',
+ !!mobiExcerpt
+ && mobiExcerpt.documentKey === mobiKey
+ && mobiExcerpt.locator.kind === 'mobi'
+ && mobiExcerpt.locator.chapter === 0
+ && Number.isInteger(mobiExcerpt.locator.offset));
+ check('MOBI 阅读器没有控制台错误', mobiReader.errors.length === 0,
+ mobiReader.errors.slice(0, 3).join(' | '));
+ const encryptedMobi = fs.readFileSync(MOBI_FILE);
+ encryptedMobi.writeUInt16BE(1, 96 + 12);
+ const drmError = await js(mobiWin, `import('./reader/mobi-adapter.mjs').then(async (module) => {
+ const adapter = module.createMobiAdapter('azw');
+ try {
+ await adapter.load(Uint8Array.from(${JSON.stringify([...encryptedMobi])}));
+ return '';
+ } catch (error) {
+ return error && error.message;
+ } finally {
+ adapter.destroy();
+ }
+ })`);
+ check('MOBI 适配器明确拒绝 DRM 文件且不尝试绕过', drmError.includes('DRM 保护'));
+ mobiWin.close();
+ const reopenedMobi = await openReader(entry.id, 2);
+ await waitForJs(reopenedMobi.win, `document.querySelector('.host-epub iframe')?.contentDocument?.body
+ ?.textContent.includes('MOBI selectable text')`, 30000);
+ check('重开 MOBI 后恢复对应文档的书签和阅读状态',
+ readerStore.getState(entry.id, mobiKey).bookmarks.length === 1
+ && await js(reopenedMobi.win, `document.getElementById('bookmarkList').textContent.includes('第 1 章')`));
+ check('重开的 MOBI 阅读器没有控制台错误', reopenedMobi.errors.length === 0,
+ reopenedMobi.errors.slice(0, 3).join(' | '));
+ reopenedMobi.win.close();
+ const drmReader = await openReader(entry.id, 3);
+ await waitForJs(drmReader.win, `document.querySelector('.doc-overlay.err .doc-overlay-msg')
+ ?.textContent.includes('DRM 保护')`, 15000);
+ check('DRM 或不兼容 AZW 打开失败时提供系统应用后备入口',
+ await js(drmReader.win, `Array.from(document.querySelectorAll('.doc-overlay.err button'))
+ .some((button) => button.textContent === '使用系统应用打开')`));
+ check('DRM 后备界面没有控制台错误', drmReader.errors.length === 0,
+ drmReader.errors.slice(0, 3).join(' | '));
+ drmReader.win.close();
+
+ const readerFile = path.join(TMP, 'reader.json');
+ const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
+ check('readerStore 使用隔离目录中的 v6 存储',
+ readerJson.version === 6
+ && fs.existsSync(readerFile)
+ && readerStore.getState(entry.id).notes.length === 6,
+ readerFile);
+ check('PDF 批注文件写入隔离 reader-annotations 目录',
+ fs.existsSync(path.join(TMP, 'reader-annotations', `${entry.id}.json`)));
+
+ epubWin.close();
+ await wait(300);
+ const managedWin = managedReader.open(entry.id, ROOT, 0, null, 'dark');
+ managedWin.hide();
+ await waitForJs(managedWin, `document.readyState === 'complete'`, 10000);
+ await wait(500);
+ check('受管阅读器就绪握手可排空队列', managedReader.markReady(managedWin.webContents));
+ await js(managedWin, `(() => {
+ const range = document.getElementById('progressRange');
+ range.value = '700';
+ range.dispatchEvent(new Event('change'));
+ })()`);
+ const removed = await js(managedWin, `window.api.library.remove(
+ ${JSON.stringify(entry.id)},
+ { deleteFiles: false, deleteReadingData: true }
+ )`);
+ const lateWrite = await js(managedWin, `window.api.reader.setProgress(
+ ${JSON.stringify(entry.id)},
+ ${JSON.stringify(annotations.documentKey(PDF_FILE))},
+ { kind: 'pdf', page: 1 },
+ 0.1
+ )`);
+ await wait(1200);
+ check('显式删除阅读资料会等待阅读器排空并移除条目', removed && removed.ok && !library.get(entry.id));
+ check('显式删除后迟到的进度和批注写入不会重建资料',
+ lateWrite && lateWrite.ok === false
+ && readerStore.getState(entry.id).notes.length === 0
+ && !fs.existsSync(path.join(TMP, 'reader-annotations', `${entry.id}.json`)));
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.close();
+ }
+ const failed = printSummary();
+ app.exit(failed ? 1 : 0);
+}).catch(async (error) => {
+ console.error('异常:', error);
+ check('集成测试未发生异常', false, error && error.stack ? error.stack.split('\n')[0] : String(error));
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.close();
+ }
+ printSummary();
+ await wait(100);
+ app.exit(1);
+});
diff --git a/src/_test/electron/startup.integration.js b/src/_test/electron/startup.integration.js
new file mode 100644
index 0000000..67d1846
--- /dev/null
+++ b/src/_test/electron/startup.integration.js
@@ -0,0 +1,77 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { app, BrowserWindow } = require('electron');
+
+const ROOT = path.resolve(__dirname, '..', '..', '..');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-startup-ui-'));
+app.setPath('appData', TMP);
+process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
+
+const results = [];
+function check(name, pass, detail) {
+ results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
+}
+
+async function waitUntil(fn, timeout = 10000) {
+ const end = Date.now() + timeout;
+ while (Date.now() < end) {
+ try {
+ const value = await fn();
+ if (value) return value;
+ } catch (e) { /* 窗口仍在加载 */ }
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
+ throw new Error(`等待条件超时(${timeout}ms)`);
+}
+
+function printSummary() {
+ console.log('\n========== 启动响应集成验证 ==========');
+ for (const [status, name, detail] of results) {
+ console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
+ }
+ const failed = results.filter((item) => item[0] === 'FAIL').length;
+ console.log(`\n通过 ${results.length - failed}/${results.length}`);
+ return failed;
+}
+
+app.whenReady().then(async () => {
+ const library = require(path.join(ROOT, 'src', 'library', 'store'));
+ let scanStartedAt = 0;
+ library.scan = () => {
+ scanStartedAt = Date.now();
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2200);
+ return { added: 0, missing: 0, total: 0 };
+ };
+
+ const startedAt = Date.now();
+ require(path.join(ROOT, 'main.js'));
+ const win = await waitUntil(() => (
+ BrowserWindow.getAllWindows().find((item) => item.getTitle() === 'PeopleLib')
+ ));
+ win.hide();
+ await waitUntil(() => win.webContents.executeJavaScript(
+ `document.readyState === 'complete'
+ && ['dark', 'light'].includes(document.documentElement.dataset.uiTheme)`
+ ));
+ const configReadyMs = Date.now() - startedAt;
+ check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
+
+ await waitUntil(() => scanStartedAt > 0, 7000);
+ check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
+ `${scanStartedAt - startedAt}ms`);
+
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.destroy();
+ }
+ const failed = printSummary();
+ app.exit(failed ? 1 : 0);
+}).catch((error) => {
+ console.error('异常:', error);
+ check('启动验证未发生异常', false, error.message || String(error));
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.destroy();
+ }
+ printSummary();
+ app.exit(1);
+});
diff --git a/src/_test/helpers.js b/src/_test/helpers.js
new file mode 100644
index 0000000..5bdf4e2
--- /dev/null
+++ b/src/_test/helpers.js
@@ -0,0 +1,116 @@
+// 测试用网络桩:在 undici 边界拦截,使 http.js 的超时/重试/cookie 逻辑全部走真实代码。
+// 必须在 require('../sources/http') 之前调用 installFetchStub。
+
+const path = require('path');
+const Module = require('module');
+
+const undiciPath = require.resolve('undici');
+const httpPath = require.resolve(path.join(__dirname, '..', 'sources', 'http.js'));
+
+let handler = null;
+const calls = [];
+
+function makeResponse({ status = 200, body = '', headers = {}, url = '' } = {}) {
+ const lower = {};
+ for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
+ const text = typeof body === 'string' ? body : JSON.stringify(body);
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ url,
+ headers: {
+ get: (k) => (k.toLowerCase() in lower ? lower[k.toLowerCase()] : null),
+ getSetCookie: () => lower['set-cookie'] || []
+ },
+ text: async () => text,
+ json: async () => JSON.parse(text),
+ body: { cancel: async () => {} }
+ };
+}
+
+// 让桩 fetch 尊重 AbortSignal,这样超时与竞速中止是真的在被验证
+function abortable(signal, work) {
+ return new Promise((resolve, reject) => {
+ if (signal && signal.aborted) {
+ const e = new Error('aborted');
+ e.name = 'AbortError';
+ return reject(e);
+ }
+ let done = false;
+ const onAbort = () => {
+ if (done) return;
+ done = true;
+ const e = new Error('aborted');
+ e.name = 'AbortError';
+ reject(e);
+ };
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
+ Promise.resolve()
+ .then(work)
+ .then((v) => { if (!done) { done = true; resolve(v); } })
+ .catch((e) => { if (!done) { done = true; reject(e); } })
+ .finally(() => { if (signal) signal.removeEventListener('abort', onAbort); });
+ });
+}
+
+function installFetchStub() {
+ const stub = {
+ exports: {
+ ProxyAgent: class { async close() {} },
+ fetch: (url, options = {}) => {
+ const u = String(url);
+ calls.push({ url: u, options });
+ if (!handler) throw new Error('未设置 fetch handler: ' + u);
+ return abortable(options.signal, () => handler(u, options));
+ }
+ },
+ loaded: true,
+ id: undiciPath,
+ filename: undiciPath,
+ paths: []
+ };
+ require.cache[undiciPath] = stub;
+}
+
+function setHandler(fn) { handler = fn; }
+function getCalls() { return calls; }
+function resetCalls() { calls.length = 0; }
+
+// 按 URL 子串匹配的路由表,未命中则抛错(避免测试静默通过)
+function routes(table) {
+ return (url) => {
+ for (const [pattern, value] of table) {
+ const hit = pattern instanceof RegExp ? pattern.test(url) : url.includes(pattern);
+ if (hit) return typeof value === 'function' ? value(url) : makeResponse(value);
+ }
+ throw new Error('未匹配的请求: ' + url);
+ };
+}
+
+// 清掉数据源与 http 的模块缓存,让每个用例拿到干净的镜像状态 / cookie jar
+function freshRequire(relPath) {
+ const target = require.resolve(path.join(__dirname, '..', relPath));
+ delete require.cache[target];
+ delete require.cache[httpPath];
+ const mirrorPath = require.resolve(path.join(__dirname, '..', 'sources', 'mirror.js'));
+ delete require.cache[mirrorPath];
+ return require(target);
+}
+
+// 从源文件里取出单个函数做隔离测试(用于未导出的内部函数与 main.js)
+function extractFns(absFile, from, to, names, preamble = '') {
+ const src = require('fs').readFileSync(absFile, 'utf8');
+ const start = src.indexOf(from);
+ if (start < 0) throw new Error(`未找到起点: ${from}`);
+ const end = to ? src.indexOf(to, start) : src.length;
+ if (to && end < 0) throw new Error(`未找到终点: ${to}`);
+ const seg = src.slice(start, end);
+ const mod = { exports: {} };
+ new Function('module', 'require', `${preamble}\n${seg}\nmodule.exports = { ${names.join(', ')} };`)(mod, require);
+ return mod.exports;
+}
+
+module.exports = {
+ installFetchStub, setHandler, getCalls, resetCalls,
+ makeResponse, routes, freshRequire, extractFns, httpPath
+};
diff --git a/src/_test/http.test.js b/src/_test/http.test.js
new file mode 100644
index 0000000..7bfe114
--- /dev/null
+++ b/src/_test/http.test.js
@@ -0,0 +1,128 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const h = require('./helpers');
+
+h.installFetchStub();
+const http = require('../sources/http');
+
+test('外部 signal 不会顶替超时保护', async () => {
+ const outer = new AbortController();
+ h.setHandler(() => new Promise(() => {}));
+ const t0 = Date.now();
+ await assert.rejects(
+ http.fetchText('https://x/slow', { timeout: 120, retries: 0, signal: outer.signal }),
+ /请求超时/
+ );
+ assert.ok(Date.now() - t0 < 2000, '超时没有生效');
+});
+
+test('外部 signal 触发时报"已取消"而不是"超时"', async () => {
+ const outer = new AbortController();
+ h.setHandler(() => new Promise(() => {}));
+ setTimeout(() => outer.abort(), 30);
+ await assert.rejects(
+ http.fetchText('https://x/cancel', { timeout: 10000, retries: 0, signal: outer.signal }),
+ /请求已取消/
+ );
+});
+
+test('取消不可重试,超时可重试', () => {
+ assert.strictEqual(http.isRetryable(new Error('请求已取消')), false);
+ assert.strictEqual(http.isRetryable(new Error('请求超时,站点无响应')), true);
+ assert.strictEqual(http.isRetryable(new Error('站点网关错误(502)')), true);
+ assert.strictEqual(http.isRetryable(new Error('资源不存在(404)')), false);
+});
+
+test('取消后不会浪费一次重试', async () => {
+ const outer = new AbortController();
+ let n = 0;
+ h.setHandler(() => { n++; return new Promise(() => {}); });
+ setTimeout(() => outer.abort(), 30);
+ await assert.rejects(
+ http.fetchText('https://x/c2', { timeout: 10000, retries: 1, signal: outer.signal }),
+ /请求已取消/
+ );
+ assert.strictEqual(n, 1, `取消后仍重试了,共 ${n} 次`);
+});
+
+test('瞬时故障会按 retries 重试', async () => {
+ let n = 0;
+ h.setHandler(() => {
+ n++;
+ if (n === 1) return h.makeResponse({ status: 502 });
+ return h.makeResponse({ body: 'ok' });
+ });
+ const out = await http.fetchText('https://x/retry', { retries: 1, retryDelay: 1 });
+ assert.strictEqual(out, 'ok');
+ assert.strictEqual(n, 2);
+});
+
+test('4xx 不重试', async () => {
+ let n = 0;
+ h.setHandler(() => { n++; return h.makeResponse({ status: 404 }); });
+ await assert.rejects(http.fetchText('https://x/404', { retries: 1, retryDelay: 1 }), /404/);
+ assert.strictEqual(n, 1, '4xx 不应重试');
+});
+
+test('fetchJson 对非 JSON 给出可读错误', async () => {
+ h.setHandler(() => h.makeResponse({ body: 'nope' }));
+ await assert.rejects(http.fetchJson('https://x/j', { retries: 0 }), /不是有效 JSON/);
+});
+
+test('setProxy 拒绝非 http(s) 协议', () => {
+ assert.throws(() => http.setProxy('socks5://127.0.0.1:1080'), /仅支持/);
+ http.setProxy('');
+ assert.strictEqual(http.getProxy(), '');
+});
+
+test('tooShort / clampPage 边界', () => {
+ assert.strictEqual(http.clampPage(0), 1);
+ assert.strictEqual(http.clampPage('abc'), 1);
+ assert.strictEqual(http.clampPage(-5), 1);
+ assert.strictEqual(http.clampPage('3'), 3);
+ assert.ok(http.tooShort('ab'));
+ assert.strictEqual(http.tooShort('abc'), null);
+});
+
+test('decodeEntities 先解数字实体再解 &,不产生二次解码', () => {
+ assert.strictEqual(http.decodeEntities('a < b'), 'a < b');
+ assert.strictEqual(http.decodeEntities('<b>'), '');
+});
+
+test('cookie 按域存取', () => {
+ http.clearCookies();
+ http.setCookies('https://a.example.com/x', ['k=1; Path=/', 'j=2']);
+ http.setCookies('https://b.example.com/y', ['z=9']);
+ assert.match(http.getCookies('https://a.example.com/other'), /k=1/);
+ assert.match(http.getCookies('https://a.example.com/other'), /j=2/);
+ assert.strictEqual(http.getCookies('https://c.example.com/'), '');
+});
+
+test('clearCookies 接受完整 URL(回退前传 URL 永远清不掉)', () => {
+ http.clearCookies();
+ http.setCookies('https://z-lib.fm/a', ['s=1']);
+ http.clearCookies('https://z-lib.fm');
+ assert.strictEqual(http.getCookies('https://z-lib.fm/a'), '', 'URL 形式的参数未生效');
+});
+
+test('clearCookies 也接受裸主机名,且不误伤其它域', () => {
+ http.clearCookies();
+ http.setCookies('https://z-lib.fm/a', ['s=1']);
+ http.setCookies('https://other.com/a', ['t=2']);
+ http.clearCookies('z-lib.fm');
+ assert.strictEqual(http.getCookies('https://z-lib.fm/a'), '');
+ assert.match(http.getCookies('https://other.com/a'), /t=2/, '误删了其它域的 cookie');
+});
+
+test('请求自动带上已存的 cookie', async () => {
+ http.clearCookies();
+ http.setCookies('https://ck.example.com/', ['sid=abc']);
+ let seen = null;
+ h.setHandler((url, opts) => {
+ seen = opts.headers.Cookie;
+ return h.makeResponse({ body: 'ok' });
+ });
+ await http.fetchText('https://ck.example.com/p', { retries: 0 });
+ assert.strictEqual(seen, 'sid=abc');
+ http.clearCookies();
+});
diff --git a/src/_test/library.test.js b/src/_test/library.test.js
new file mode 100644
index 0000000..4ed2c25
--- /dev/null
+++ b/src/_test/library.test.js
@@ -0,0 +1,263 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const store = require('../library/store');
+
+const created = [];
+
+function freshRoot(tag) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-library-${tag}-`));
+ created.push(root);
+ store.init(root);
+ return root;
+}
+
+function indexPath(root) {
+ return path.join(root, 'library.json');
+}
+
+test.after(() => {
+ store.setChangeListener(null);
+ for (const root of created) {
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) { /* ignore */ }
+ }
+});
+
+test('managed tags support CRUD, validation, persistence, and zero-use entries', () => {
+ const root = freshRoot('crud');
+ const tag = store.addTag({ name: ' 技术 ' });
+ assert.match(tag.id, /^tag_[a-f0-9]{24}$/);
+ assert.strictEqual(tag.name, '技术');
+ assert.ok(Number.isFinite(tag.createdAt));
+ assert.ok(Number.isFinite(tag.updatedAt));
+ assert.deepStrictEqual(store.listTags(), [{ ...tag, count: 0 }]);
+
+ assert.throws(() => store.addTag(' '), /不能为空/);
+ assert.throws(() => store.addTag('技术'), /已存在/);
+ assert.throws(() => store.addTag(' 技术 '), /已存在/);
+ assert.throws(() => store.addTag('x'.repeat(65)), /64/);
+ assert.throws(() => store.updateTag(tag.id, { name: '' }), /不能为空/);
+ assert.throws(() => store.updateTag('missing', { name: '新标签' }), /不存在/);
+
+ const renamed = store.updateTag(tag.id, { name: ' 文学 ' });
+ assert.strictEqual(renamed.id, tag.id);
+ assert.strictEqual(renamed.name, '文学');
+ assert.strictEqual(renamed.createdAt, tag.createdAt);
+ assert.ok(renamed.updatedAt >= tag.updatedAt);
+
+ store.init(root);
+ assert.deepStrictEqual(store.listTags(), [{ ...renamed, count: 0 }]);
+ const persisted = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
+ assert.strictEqual(persisted.version, 4);
+ assert.deepStrictEqual(persisted.tags, [renamed]);
+
+ assert.deepStrictEqual(store.removeTag(tag.id), { removed: true });
+ assert.deepStrictEqual(store.removeTag(tag.id), { removed: false });
+ assert.deepStrictEqual(store.listTags(), []);
+});
+
+test('renaming and deleting tags update every item atomically without deleting books', () => {
+ freshRoot('propagation');
+ let changes = 0;
+ store.setChangeListener(() => { changes++; });
+ try {
+ const tag = store.addTag('Work');
+ const first = store.add({ title: '一', tags: ['work', 'Other'] });
+ const second = store.add({ title: '二', tags: ['WORK'] });
+ assert.strictEqual(store.listTags().find((entry) => entry.id === tag.id).count, 2);
+
+ const renamed = store.updateTag(tag.id, { name: 'Research' });
+ assert.strictEqual(renamed.name, 'Research');
+ assert.deepStrictEqual(store.get(first.id).tags, ['Research', 'Other']);
+ assert.deepStrictEqual(store.get(second.id).tags, ['Research']);
+ assert.strictEqual(store.listTags().find((entry) => entry.id === tag.id).count, 2);
+ assert.ok(!store.listTags().some((entry) => entry.name.toLowerCase() === 'work'));
+
+ assert.deepStrictEqual(store.removeTag(tag.id), { removed: true });
+ assert.strictEqual(store.list().length, 2);
+ assert.deepStrictEqual(store.get(first.id).tags, ['Other']);
+ assert.deepStrictEqual(store.get(second.id).tags, []);
+ assert.ok(store.listTags().some((entry) => entry.name === 'Other'));
+ assert.strictEqual(changes, 5, 'tag CRUD and tagged item organization changes should notify');
+ } finally {
+ store.setChangeListener(null);
+ }
+});
+
+test('add and update automatically catalog unseen item tags and retain them at zero use', () => {
+ const root = freshRoot('automatic');
+ const book = store.add({ title: '自动', tags: [' Alpha ', 'alpha'] });
+ store.update(book.id, { tags: ['Beta'] });
+
+ let listed = store.listTags();
+ assert.deepStrictEqual(
+ listed.map((entry) => [entry.name, entry.count]),
+ [['Beta', 1], ['Alpha', 0]]
+ );
+
+ store.remove(book.id, false);
+ listed = store.listTags();
+ assert.deepStrictEqual(
+ listed.map((entry) => [entry.name, entry.count]).sort(),
+ [['Alpha', 0], ['Beta', 0]]
+ );
+
+ store.init(root);
+ assert.deepStrictEqual(
+ store.listTags().map((entry) => [entry.name, entry.count]).sort(),
+ [['Alpha', 0], ['Beta', 0]]
+ );
+ const raw = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
+ assert.deepStrictEqual(raw.items, []);
+ assert.deepStrictEqual(raw.tags.map((entry) => entry.name).sort(), ['Alpha', 'Beta']);
+});
+
+test('v1 through v3 indexes migrate to a normalized v4 tag catalog', () => {
+ const fixtures = [
+ {
+ version: 1,
+ data: [{ id: 'v1', title: '一', tags: [' Alpha ', 'alpha'] }],
+ expected: 'Alpha'
+ },
+ {
+ version: 2,
+ data: { version: 2, items: [{ id: 'v2', title: '二', tags: ['BETA'] }] },
+ expected: 'BETA'
+ },
+ {
+ version: 3,
+ data: {
+ version: 3,
+ shelves: [],
+ items: [{ id: 'v3', title: '三', tags: [' 伽马 ', '伽马'] }]
+ },
+ expected: '伽马'
+ }
+ ];
+
+ for (const fixture of fixtures) {
+ const root = freshRoot(`schema-v${fixture.version}`);
+ fs.writeFileSync(indexPath(root), JSON.stringify(fixture.data));
+ store.init(root);
+ const migrated = store.listTags();
+ assert.strictEqual(migrated.length, 1);
+ assert.strictEqual(migrated[0].name, fixture.expected);
+ assert.strictEqual(migrated[0].count, 1);
+
+ store.addTag(`零使用-${fixture.version}`);
+ const raw = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
+ assert.strictEqual(raw.version, 4);
+ assert.strictEqual(raw.tags.length, 2);
+ assert.strictEqual(raw.tags[0].name, fixture.expected);
+ assert.match(raw.tags[0].id, /^tag_[a-f0-9]{24}$/);
+ assert.deepStrictEqual(raw.items[0].tags, [fixture.expected]);
+ }
+});
+
+test('legacy import merges managed and item tags case-insensitively', () => {
+ const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-library-legacy-'));
+ created.push(legacy);
+ fs.writeFileSync(indexPath(legacy), JSON.stringify({
+ version: 4,
+ shelves: [],
+ tags: [
+ { id: 'legacy-existing', name: 'existing', createdAt: 10, updatedAt: 20 },
+ { id: 'legacy-zero', name: 'Legacy Zero', createdAt: 30, updatedAt: 40 }
+ ],
+ items: [{ id: 'legacy-book', title: '旧书', tags: ['Imported Item'] }]
+ }));
+
+ freshRoot('legacy-destination');
+ const existing = store.addTag('Existing');
+ assert.strictEqual(store.importLegacy(legacy).imported, 1);
+
+ const listed = store.listTags();
+ assert.strictEqual(listed.filter((entry) => entry.name.toLowerCase() === 'existing').length, 1);
+ assert.strictEqual(listed.find((entry) => entry.id === existing.id).name, 'Existing');
+ assert.strictEqual(listed.find((entry) => entry.name === 'Legacy Zero').count, 0);
+ assert.strictEqual(listed.find((entry) => entry.name === 'Imported Item').count, 1);
+ assert.strictEqual(store.get('legacy-book').tags[0], 'Imported Item');
+});
+
+test('listTags is deterministically ordered and deeply cloned', () => {
+ freshRoot('list');
+ store.addTag('零');
+ store.add({ title: '一', tags: ['Zulu', '中文'] });
+ store.add({ title: '二', tags: ['zulu', 'Alpha'] });
+
+ const listed = store.listTags();
+ assert.deepStrictEqual(
+ listed.map((entry) => [entry.name, entry.count]),
+ [
+ ['Zulu', 2],
+ ...[
+ ['Alpha', 1],
+ ['中文', 1]
+ ].sort((a, b) => a[0].localeCompare(b[0], 'zh-CN', { sensitivity: 'base' })),
+ ['零', 0]
+ ]
+ );
+
+ listed[0].name = '外部修改';
+ listed[0].count = 999;
+ listed.push({ id: 'fake', name: '假的', count: 1 });
+ const again = store.listTags();
+ assert.strictEqual(again.length, 4);
+ assert.strictEqual(again[0].name, 'Zulu');
+ assert.strictEqual(again[0].count, 2);
+});
+
+test('failed tag writes roll back both catalog and item references', () => {
+ const root = freshRoot('rollback');
+ const tag = store.addTag('Before');
+ const book = store.add({ title: '书', tags: ['before'] });
+ const file = indexPath(root);
+ const beforeFile = fs.readFileSync(file, 'utf8');
+ const beforeTags = store.listTags();
+ const originalRename = fs.renameSync;
+ let failed = false;
+ fs.renameSync = function renameWithFailure(source, destination) {
+ if (!failed && source === `${file}.tmp` && destination === file) {
+ failed = true;
+ throw new Error('simulated replace failure');
+ }
+ return originalRename.apply(this, arguments);
+ };
+ try {
+ assert.throws(
+ () => store.updateTag(tag.id, { name: 'After' }),
+ /书库索引写入失败/
+ );
+ } finally {
+ fs.renameSync = originalRename;
+ }
+
+ assert.ok(failed);
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), beforeFile);
+ assert.deepStrictEqual(store.listTags(), beforeTags);
+ assert.deepStrictEqual(store.get(book.id).tags, ['before']);
+ assert.ok(!fs.existsSync(`${file}.tmp`));
+ assert.ok(!fs.existsSync(`${file}.bak`));
+});
+
+test('explicit tag creation enforces the existing catalog limit', () => {
+ const root = freshRoot('limit');
+ const now = Date.now();
+ fs.writeFileSync(indexPath(root), JSON.stringify({
+ version: 4,
+ shelves: [],
+ tags: Array.from({ length: 50 }, (_, i) => ({
+ id: `tag-seeded-${i}`,
+ name: `Tag ${i}`,
+ createdAt: now,
+ updatedAt: now
+ })),
+ items: []
+ }));
+ store.init(root);
+ assert.throws(() => store.addTag('One Too Many'), /50/);
+ assert.strictEqual(store.listTags().length, 50);
+});
diff --git a/src/_test/local-import.test.js b/src/_test/local-import.test.js
new file mode 100644
index 0000000..0727372
--- /dev/null
+++ b/src/_test/local-import.test.js
@@ -0,0 +1,156 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { discover } = require('../library/local-import');
+
+const created = [];
+
+function freshRoot(tag) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-local-import-${tag}-`));
+ created.push(root);
+ return root;
+}
+
+function write(root, relativePath, contents = '') {
+ const target = path.join(root, relativePath);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, contents);
+ return target;
+}
+
+test.after(() => {
+ for (const root of created) {
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch (error) { /* ignore */ }
+ }
+});
+
+test('discovers a directly selected supported file with a canonical record', async () => {
+ const root = freshRoot('direct');
+ const selected = write(root, 'A Book.PDF');
+ const canonical = fs.realpathSync(selected);
+
+ assert.deepStrictEqual(await discover([selected]), [{
+ path: canonical,
+ name: 'A Book.PDF',
+ format: 'pdf',
+ parentName: path.basename(root)
+ }]);
+ assert.ok(path.isAbsolute(canonical));
+});
+
+test('recursively discovers supported files and uses each immediate parent name', async () => {
+ const root = freshRoot('recursive');
+ const first = write(root, 'root.epub');
+ const second = write(root, path.join('Shelf One', 'nested.MOBI'));
+ const third = write(root, path.join('Shelf One', 'Deeper', 'last.fb2'));
+
+ const result = await discover([root]);
+ const byName = new Map(result.map((record) => [record.name, record]));
+
+ assert.deepStrictEqual(
+ new Set(result.map((record) => record.path)),
+ new Set([first, second, third].map((value) => fs.realpathSync(value)))
+ );
+ assert.strictEqual(byName.get('root.epub').parentName, path.basename(root));
+ assert.strictEqual(byName.get('nested.MOBI').parentName, 'Shelf One');
+ assert.strictEqual(byName.get('last.fb2').parentName, 'Deeper');
+ assert.deepStrictEqual(
+ Object.fromEntries(result.map((record) => [record.name, record.format])),
+ { 'last.fb2': 'fb2', 'nested.MOBI': 'mobi', 'root.epub': 'epub' }
+ );
+});
+
+test('handles mixed file and directory inputs while skipping unsupported and non-files', async () => {
+ const root = freshRoot('mixed');
+ const folder = path.join(root, 'folder');
+ const inFolder = write(root, path.join('folder', 'comic.cbz'));
+ const azw = write(root, path.join('folder', 'legacy.azw'));
+ const direct = write(root, 'notes.txt');
+ write(root, path.join('folder', 'cover.jpg'));
+ write(root, 'README.md');
+ fs.mkdirSync(path.join(root, 'empty'));
+
+ const result = await discover([
+ path.join(root, 'missing.pdf'),
+ path.join(root, 'README.md'),
+ path.join(root, 'empty'),
+ direct,
+ folder
+ ]);
+
+ assert.deepStrictEqual(
+ result.map((record) => record.path),
+ [inFolder, azw, direct].map((value) => fs.realpathSync(value)).sort()
+ );
+});
+
+test('de-duplicates repeated selections', async () => {
+ const root = freshRoot('duplicate');
+ const selected = write(root, 'duplicate.djvu');
+
+ const result = await discover([selected, root, selected]);
+ assert.strictEqual(result.length, 1);
+ assert.strictEqual(result[0].path, fs.realpathSync(selected));
+});
+
+test('does not follow symbolic links to files or directories when links are available', async (t) => {
+ const root = freshRoot('symlink');
+ const outside = freshRoot('symlink-target');
+ const ordinary = write(root, 'ordinary.cbr');
+ const linkedFileTarget = write(outside, 'linked.pdf');
+ const linkedDirectoryTarget = path.join(outside, 'books');
+ const nestedTarget = write(outside, path.join('books', 'nested.azw3'));
+ const fileLink = path.join(root, 'file-link.pdf');
+ const directoryLink = path.join(root, 'directory-link');
+
+ try {
+ fs.symlinkSync(linkedFileTarget, fileLink, 'file');
+ fs.symlinkSync(
+ linkedDirectoryTarget,
+ directoryLink,
+ process.platform === 'win32' ? 'junction' : 'dir'
+ );
+ } catch (error) {
+ t.skip(`symbolic links are unavailable: ${error.code || error.message}`);
+ return;
+ }
+
+ const result = await discover([root, fileLink, directoryLink]);
+ assert.deepStrictEqual(result.map((record) => record.path), [fs.realpathSync(ordinary)]);
+ assert.ok(!result.some((record) => record.path === fs.realpathSync(nestedTarget)));
+});
+
+test('returns a deterministic path-sorted order independent of selection order', async () => {
+ const root = freshRoot('order');
+ write(root, 'zeta.txt');
+ write(root, 'Alpha.pdf');
+ write(root, path.join('middle', 'beta.epub'));
+
+ const forward = await discover([path.join(root, 'zeta.txt'), path.join(root, 'middle'), root]);
+ const reverse = await discover([root, path.join(root, 'middle'), path.join(root, 'zeta.txt')]);
+
+ assert.deepStrictEqual(forward, reverse);
+ assert.deepStrictEqual(
+ forward.map((record) => record.path),
+ forward.map((record) => record.path).slice().sort((left, right) => {
+ const leftKey = process.platform === 'win32' ? left.toLowerCase() : left;
+ const rightKey = process.platform === 'win32' ? right.toLowerCase() : right;
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left < right ? -1 : left > right ? 1 : 0;
+ })
+ );
+});
+
+test('throws a clear Chinese error when the supported-file maximum is exceeded', async () => {
+ const root = freshRoot('maximum');
+ write(root, 'one.pdf');
+ write(root, 'two.epub');
+ write(root, 'three.mobi');
+
+ await assert.rejects(
+ discover([root], { maxFiles: 2 }),
+ /本地导入文件数量超过上限(最多 2 个)/
+ );
+});
diff --git a/src/_test/main.test.js b/src/_test/main.test.js
new file mode 100644
index 0000000..eed0950
--- /dev/null
+++ b/src/_test/main.test.js
@@ -0,0 +1,225 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+const h = require('./helpers');
+
+const mainFile = path.join(__dirname, '..', '..', 'main.js');
+const mainSrc = fs.readFileSync(mainFile, 'utf8');
+
+const { compareVersion } = h.extractFns(
+ mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion']
+);
+const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']);
+
+test('wrap 捕获同步抛出,不让 invoke reject', async () => {
+ const r = await wrap(() => { throw new Error('未知数据源: nope'); });
+ assert.deepStrictEqual(r, { ok: false, error: '未知数据源: nope' });
+});
+
+test('wrap 捕获异步拒绝', async () => {
+ const r = await wrap(() => Promise.reject(new Error('boom')));
+ assert.strictEqual(r.ok, false);
+ assert.strictEqual(r.error, 'boom');
+});
+
+test('wrap 正常返回包成 { ok:true, data }', async () => {
+ assert.deepStrictEqual(await wrap(() => 42), { ok: true, data: 42 });
+ assert.deepStrictEqual(await wrap(() => Promise.resolve('x')), { ok: true, data: 'x' });
+});
+
+test('wrap 对非 Error 抛出也能给出字符串', async () => {
+ const r = await wrap(() => { throw 'plain string'; });
+ assert.strictEqual(r.ok, false);
+ assert.strictEqual(r.error, 'plain string');
+});
+
+test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
+ assert.ok(/function wrap\(fn\)/.test(mainSrc), 'wrap 未改成接收函数');
+ assert.ok(!/wrap\(sources\.getSource/.test(mainSrc), '仍有同步求值的 getSource 传进 wrap');
+ assert.ok(!/wrap\(Promise\.resolve/.test(mainSrc), '仍有 Promise.resolve 被提前求值');
+ // 直接返回 { ok: true, ... } 而不过 wrap 的 handler 会绕开错误处理
+ const bare = mainSrc.match(/ipcMain\.handle\([^)]*=>\s*\(\{\s*ok:\s*true/g) || [];
+ assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
+});
+
+test('版本比较:预发布版本低于同号正式版', () => {
+ assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
+ assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
+ assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0-beta'), 0);
+});
+
+test('版本比较:常规大小与位数不等', () => {
+ assert.strictEqual(compareVersion('1.2.0', '1.1.9'), 1);
+ assert.strictEqual(compareVersion('1.1.0', '1.1.0'), 0);
+ assert.strictEqual(compareVersion('2.0', '1.9.9'), 1);
+ assert.strictEqual(compareVersion('1.10.0', '1.9.0'), 1, '按数值而非字典序比较');
+ assert.strictEqual(compareVersion('v1.1.1', '1.1.0'), 1, '应容忍 v 前缀');
+});
+
+test('窗口控制 handler 检查 isDestroyed', () => {
+ assert.ok(/function liveWindow\(\)/.test(mainSrc), '缺少 liveWindow 守卫');
+ assert.ok(/isDestroyed\(\)\s*\?\s*null\s*:\s*mainWindow/.test(mainSrc.replace(/\s+/g, ' ')) ||
+ /!mainWindow\.isDestroyed\(\)/.test(mainSrc), 'liveWindow 未检查 isDestroyed');
+ assert.ok(!/mainWindow && mainWindow\.minimize\(\)/.test(mainSrc), '仍有未加守卫的窗口调用');
+ assert.ok(!/dialog\.show\w+\(mainWindow,/.test(mainSrc), '对话框仍直接引用可能已销毁的窗口');
+});
+
+test('下载校验协议,拒绝 file:// 等非 http(s)', () => {
+ assert.ok(/仅支持 HTTP 或 HTTPS 下载链接/.test(mainSrc));
+ assert.ok(/仅允许打开 HTTP 或 HTTPS 链接/.test(mainSrc), 'openExternal 缺协议校验');
+});
+
+test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
+ const start = mainSrc.indexOf("ipcMain.handle('library:remove'");
+ const end = mainSrc.indexOf('// 下载文件', start);
+ const segment = mainSrc.slice(start, end);
+ assert.match(segment, /options\.deleteReadingData\s*===\s*true/);
+ const guard = segment.indexOf('if (deleteReadingData)');
+ assert.ok(guard >= 0, '缺少显式清理守卫');
+ assert.ok(segment.indexOf('readerStore.forget', guard) > guard);
+ assert.ok(segment.indexOf('annotations.forget', guard) > guard);
+});
+
+test('书库列表附带阅读记录中的最近阅读时间', () => {
+ const start = mainSrc.indexOf("ipcMain.handle('library:list'");
+ const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
+ const segment = mainSrc.slice(start, end);
+ assert.match(segment, /lastReadAt:\s*readerStore\.getLastReadAt\(item\.id\)/);
+});
+
+test('Z-Library 登录通过受限同源浏览器完成反机器人验证', () => {
+ const start = mainSrc.indexOf('async function browserZlibLogin');
+ const end = mainSrc.indexOf('// Z-Library 凭据', start);
+ const segment = mainSrc.slice(start, end);
+ assert.ok(start > 0 && end > start);
+ assert.match(segment, /show:\s*false/);
+ assert.match(segment, /contextIsolation:\s*true/);
+ assert.match(segment, /nodeIntegration:\s*false/);
+ assert.match(segment, /sandbox:\s*true/);
+ assert.match(segment, /setWindowOpenHandler\(\(\)\s*=>\s*\(\{\s*action:\s*'deny'/);
+ assert.match(segment, /new URL\(target\)\.origin\s*!==\s*origin/);
+ assert.match(segment, /executeJavaScriptInIsolatedWorld/);
+ assert.match(segment, /session\.defaultSession\.cookies\.get/);
+ assert.match(segment, /if \(!win\.isDestroyed\(\)\) win\.destroy\(\)/);
+});
+
+test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => {
+ assert.match(mainSrc, /event\.sender\.send\('download:progress'/);
+ assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/);
+ assert.match(mainSrc, /percent:\s*totalBytes\s*\?\s*Math\.min\(1,\s*receivedBytes\s*\/\s*totalBytes\)\s*:\s*null/);
+ assert.match(mainSrc, /percent:\s*1,\s*complete:\s*true/);
+});
+
+test('窗口使用 icons/dist 主题图标并同步界面主题', () => {
+ const iconDir = path.join(__dirname, '..', '..', 'icons', 'dist');
+ for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
+ const bytes = fs.readFileSync(path.join(iconDir, name));
+ assert.deepStrictEqual([...bytes.subarray(0, 4)], [0, 0, 1, 0], `${name} 不是 ICO`);
+ }
+ assert.match(mainSrc, /function iconForTheme\(theme\)/);
+ assert.match(mainSrc, /icon:\s*iconForTheme\(currentUiTheme\)/);
+ assert.match(mainSrc, /ipcMain\.handle\('ui:setTheme'/);
+ assert.match(mainSrc, /settings\.set\('ui\.theme', theme\)/);
+ assert.match(mainSrc, /settings\.set\('reader\.uiTheme', theme\)/);
+ const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
+ assert.match(build, /rcedit\(executable,[\s\S]*book-ai-dark\.ico/);
+ assert.match(build, /book-ai-light\.ico/);
+});
+
+test('标准构建入口固定输出目录并保留便携数据', () => {
+ const root = path.join(__dirname, '..', '..');
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
+ const build = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
+ assert.strictEqual(pkg.scripts.build, 'node build-portable.js');
+ assert.match(build, /const OUT = REQUESTED_OUT/);
+ assert.match(build, /if \(entry\.name === 'data'\) continue/);
+ assert.match(build, /clearOutput\(OUT\)/);
+ assert.match(build, /请先关闭其中正在运行的/);
+ assert.doesNotMatch(build, /nextAvailableOutput|-rebuild/);
+ // 目录名固定为平台标识,升级版本不再产生新目录,data/ 也就不会被落在旧目录里
+ assert.match(build, /const TARGET = `\$\{PRODUCT\}-windows-x64`/);
+ assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/);
+});
+
+test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => {
+ assert.match(mainSrc, /await requestReaderPurge\(id\)/);
+ assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);
+ assert.match(mainSrc, /function ensureReaderWritable\(entryId\)/);
+ assert.match(mainSrc, /ipcMain\.on\('reader:purgeReady'/);
+ const forgetAt = mainSrc.indexOf('readerStore.forget(id)');
+ const removeAt = mainSrc.indexOf('library.remove(id, deleteFiles)');
+ assert.ok(forgetAt > 0 && removeAt > forgetAt, '显式阅读资料清理必须在移除书库条目前成功');
+ assert.doesNotMatch(mainSrc, /readerStore\.forget\(id\);\s*\}\s*catch\s*\(e\)\s*\{\s*\/\*[^*]*不该阻断移除/);
+});
+
+test('笔记文档指纹失配时拒绝回退到其它文件', () => {
+ assert.match(mainSrc, /if \(matched < 0\) throw new Error\('笔记关联的原始文件已变更或不存在'\)/);
+});
+
+test('启动扫描和旧库导入在首屏配置加载后延迟执行', () => {
+ assert.match(mainSrc, /webContents\.once\('did-finish-load'/);
+ assert.match(mainSrc, /setTimeout\(runStartupMaintenance,\s*1500\)/);
+ const maintenanceAt = mainSrc.indexOf('function runStartupMaintenance()');
+ const legacyAt = mainSrc.indexOf('library.importLegacy(userDataDir)', maintenanceAt);
+ const scanAt = mainSrc.indexOf('library.scan()', maintenanceAt);
+ assert.ok(maintenanceAt > 0 && legacyAt > maintenanceAt && scanAt > legacyAt);
+});
+
+test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌', () => {
+ const start = mainSrc.indexOf("ipcMain.handle('dialog:pickLocal'");
+ const end = mainSrc.indexOf('// --- 阅读器 ---', start);
+ const segment = mainSrc.slice(start, end);
+ assert.ok(start > 0 && end > start);
+ assert.match(segment, /localImport\.discover\(r\.filePaths\)/);
+ assert.match(segment, /senderId:\s*event\.sender\.id/);
+ assert.match(segment, /pending\.senderId\s*!==\s*event\.sender\.id/);
+ assert.match(segment, /pendingLocalImports\.delete\(id\)/);
+ assert.match(segment, /10\s*\*\s*60\s*\*\s*1000/);
+ assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
+});
+
+test('内置阅读器允许 PDF、EPUB 和无 DRM Kindle 容器并保留外部回退', () => {
+ assert.match(
+ mainSrc,
+ /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/
+ );
+ assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
+ assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
+ assert.match(mainSrc, /if \(resolved\.format !== 'pdf'\) throw new Error\('只有 PDF 支持页面批注'\)/);
+});
+
+test('PDF 使用发送者隔离的分段读取且不再整文件经过 IPC', () => {
+ assert.match(mainSrc, /ipcMain\.handle\('reader:rangeOpen'/);
+ assert.match(mainSrc, /ipcMain\.handle\('reader:rangeRead'/);
+ assert.match(mainSrc, /ipcMain\.handle\('reader:rangeClose'/);
+ assert.match(mainSrc, /isReaderSender\(event\.sender\)/);
+ assert.match(mainSrc, /rangeSessions\.closeSender\(senderId\)/);
+ const start = mainSrc.indexOf("ipcMain.handle('reader:bytes'");
+ const end = mainSrc.indexOf("ipcMain.handle('reader:openExternal'", start);
+ const segment = mainSrc.slice(start, end);
+ assert.match(segment, /format === 'pdf'/);
+ assert.match(segment, /readBoundedFile\(abs, MAX_BUFFERED_READER_BYTES\)/);
+ assert.doesNotMatch(segment, /fs\.readFileSync\(abs\)/);
+
+ const ranges = fs.readFileSync(path.join(__dirname, '..', 'reader', 'range-sessions.js'), 'utf8');
+ assert.match(ranges, /MAX_RANGE_BYTES\s*=\s*4\s*\*\s*1024\s*\*\s*1024/);
+ assert.match(ranges, /session\.senderId\s*!==\s*senderIdOf\(senderId\)/);
+ assert.match(ranges, /session\.handle\.read\(buffer,\s*offset,\s*length - offset,\s*start \+ offset\)/);
+ assert.match(ranges, /PDF 文件在阅读期间发生变化/);
+});
+
+test('AI 图像与取消请求受阅读器发送者和资源边界保护', () => {
+ assert.match(mainSrc, /function isReaderSender\(webContents\)/);
+ assert.match(mainSrc, /ipcMain\.handle\('reader:captureRect'/);
+ assert.match(mainSrc, /function canonicalVisualContexts\(raw\)/);
+ assert.match(mainSrc, /nativeImage\.createFromBuffer/);
+ assert.match(mainSrc, /decoded\.toJPEG\(85\)/);
+ assert.match(mainSrc, /function aiRunKey\(senderId, runId\)/);
+ assert.match(mainSrc, /aiRunKey\(event\.sender\.id/);
+ assert.match(mainSrc, /aiRunKey\(e\.sender\.id/);
+ assert.match(mainSrc, /wc\.once\('destroyed', abortOnDestroy\)/);
+ assert.match(mainSrc, /只有阅读器可以使用 AI 助手/);
+ assert.match(mainSrc, /function notifyAiChanged\(status\)/);
+ assert.match(mainSrc, /webContents\.send\('ai:changed', status\)/);
+});
diff --git a/src/_test/mirror.test.js b/src/_test/mirror.test.js
new file mode 100644
index 0000000..9f398bb
--- /dev/null
+++ b/src/_test/mirror.test.js
@@ -0,0 +1,118 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const path = require('path');
+
+function freshMirror() {
+ const p = require.resolve(path.join(__dirname, '..', 'sources', 'mirror.js'));
+ delete require.cache[p];
+ return require(p);
+}
+
+test('tryMirrors: 内容级错误立即返回,不再试其它镜像', async () => {
+ const m = freshMirror();
+ const tried = [];
+ await assert.rejects(
+ m.tryMirrors('p1', ['a', 'b', 'c'], async (x) => {
+ tried.push(x);
+ throw m.contentError('该 DOI 不存在');
+ }),
+ /不存在/
+ );
+ assert.deepStrictEqual(tried, ['a'], '内容级错误不该继续轮询');
+});
+
+test('tryMirrors: 内容级错误不拉黑镜像,下次仍优先使用', async () => {
+ const m = freshMirror();
+ await assert.rejects(m.tryMirrors('p2', ['a', 'b'], async () => {
+ throw m.contentError('没有这篇');
+ }));
+ const tried = [];
+ await m.tryMirrors('p2', ['a', 'b'], async (x) => { tried.push(x); return 'ok'; });
+ assert.strictEqual(tried[0], 'a', '健康镜像被误拉黑了');
+});
+
+test('tryMirrors: 真实网络故障会依次换镜像', async () => {
+ const m = freshMirror();
+ const tried = [];
+ const r = await m.tryMirrors('p3', ['a', 'b', 'c'], async (x) => {
+ tried.push(x);
+ if (x !== 'c') throw new Error('网络连接失败,请检查网络或代理设置');
+ return 'ok';
+ });
+ assert.strictEqual(r, 'ok');
+ assert.deepStrictEqual(tried, ['a', 'b', 'c']);
+});
+
+test('tryMirrors: 成功镜像会被记住并优先', async () => {
+ const m = freshMirror();
+ await m.tryMirrors('p4', ['a', 'b', 'c'], async (x) => {
+ if (x !== 'c') throw new Error('请求超时,站点无响应');
+ return 'ok';
+ });
+ const tried = [];
+ await m.tryMirrors('p4', ['a', 'b', 'c'], async (x) => { tried.push(x); return 'ok'; });
+ assert.strictEqual(tried[0], 'c', '上次成功的镜像没有被优先');
+});
+
+test('tryMirrors: 全部失败时抛出最后一个错误', async () => {
+ const m = freshMirror();
+ await assert.rejects(
+ m.tryMirrors('p5', ['a', 'b'], async () => { throw new Error('请求超时,站点无响应'); }),
+ /超时/
+ );
+});
+
+test('raceMirrors: 返回最快成功的结果', async () => {
+ const m = freshMirror();
+ const r = await m.raceMirrors('r1', ['slow', 'fast'], async (x) => {
+ if (x === 'slow') { await new Promise((s) => setTimeout(s, 200)); return 'slow'; }
+ return 'fast';
+ });
+ assert.strictEqual(r, 'fast');
+});
+
+test('raceMirrors: 胜出后中止其余在途请求', async () => {
+ const m = freshMirror();
+ let aborted = false;
+ const r = await m.raceMirrors('r2', ['loser', 'winner'], async (x, signal) => {
+ if (x === 'winner') return 'w';
+ return new Promise((_res, rej) => {
+ signal.addEventListener('abort', () => { aborted = true; rej(new Error('请求已取消')); });
+ });
+ });
+ assert.strictEqual(r, 'w');
+ await new Promise((s) => setTimeout(s, 20));
+ assert.ok(aborted, '败者没有被中止');
+});
+
+test('raceMirrors: 跳过已拉黑镜像', async () => {
+ const m = freshMirror();
+ // 先让 bad 因真实故障进黑名单
+ await m.raceMirrors('r3', ['bad', 'good'], async (x) => {
+ if (x === 'bad') throw new Error('网络连接失败,请检查网络或代理设置');
+ return 'ok';
+ });
+ const tried = [];
+ await m.raceMirrors('r3', ['bad', 'good'], async (x) => { tried.push(x); return 'ok'; });
+ assert.ok(!tried.includes('bad'), '黑名单在竞速模式下失效了');
+});
+
+test('raceMirrors: 全部失败时 reject 而不是挂起', async () => {
+ const m = freshMirror();
+ await assert.rejects(
+ m.raceMirrors('r4', ['a', 'b'], async () => { throw new Error('请求超时,站点无响应'); }),
+ /超时/
+ );
+});
+
+test('raceMirrors: 内容级错误直接结束竞速', async () => {
+ const m = freshMirror();
+ await assert.rejects(
+ m.raceMirrors('r5', ['a', 'b'], async (x) => {
+ if (x === 'a') throw m.contentError('页面结构无法识别');
+ await new Promise((s) => setTimeout(s, 500));
+ return 'late';
+ }),
+ /页面结构无法识别/
+ );
+});
diff --git a/src/_test/note-assets.test.js b/src/_test/note-assets.test.js
new file mode 100644
index 0000000..7dc590a
--- /dev/null
+++ b/src/_test/note-assets.test.js
@@ -0,0 +1,96 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+function fresh() {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-note-assets-'));
+ delete require.cache[require.resolve('../reader/note-assets')];
+ const assets = require('../reader/note-assets');
+ assets.init(root);
+ return { root, assets };
+}
+
+function pdf(file, suffix = '') {
+ fs.writeFileSync(file, `%PDF-1.4\n${suffix}\n%%EOF\n`);
+}
+
+test('PDF 底版选择令牌绑定渲染进程且只能由保存流程解析', () => {
+ const { root, assets } = fresh();
+ const file = path.join(root, 'paper.pdf');
+ pdf(file, 'page one');
+ const staged = assets.stagePdf(file, 101);
+ assert.match(staged.token, /^[0-9a-f-]{36}$/);
+ assert.strictEqual(staged.name, 'paper.pdf');
+ assert.throws(() => assets.readDraft(staged.token, 202), /选择已失效/);
+ assert.match(assets.readDraft(staged.token, 101).subarray(0, 5).toString(), /^%PDF-/);
+
+ const canvasContent = {
+ version: 1,
+ pages: [{
+ id: 'pg_one',
+ width: 612,
+ height: 792,
+ background: { type: 'pdf', page: 1, draftToken: staged.token },
+ objects: []
+ }]
+ };
+ const resolved = assets.resolveDrafts(canvasContent, 101);
+ assert.match(resolved.content.pages[0].background.assetId, /^pdf_[a-f0-9]{64}$/);
+ assert.ok(!Object.prototype.hasOwnProperty.call(
+ resolved.content.pages[0].background,
+ 'draftToken'
+ ));
+ assert.deepStrictEqual(resolved.tokens, [staged.token]);
+ assets.commitTokens(resolved.tokens);
+ assert.throws(() => assets.readDraft(staged.token, 101), /选择已失效/);
+ assert.match(assets.readAsset(resolved.content.pages[0].background.assetId).toString(), /page one/);
+});
+
+test('相同 PDF 复用内容资源并按引用集合清理孤儿', () => {
+ const { root, assets } = fresh();
+ const first = path.join(root, 'first.pdf');
+ const copy = path.join(root, 'copy.pdf');
+ const other = path.join(root, 'other.pdf');
+ pdf(first, 'same bytes');
+ fs.copyFileSync(first, copy);
+ pdf(other, 'different');
+ const a = assets.stagePdf(first, 1);
+ const b = assets.stagePdf(copy, 1);
+ const c = assets.stagePdf(other, 1);
+ const resolve = (token) => assets.resolveDrafts({
+ version: 1,
+ pages: [{
+ id: 'pg_one',
+ width: 612,
+ height: 792,
+ background: { type: 'pdf', page: 1, draftToken: token },
+ objects: []
+ }]
+ }, 1);
+ const ar = resolve(a.token);
+ const br = resolve(b.token);
+ const cr = resolve(c.token);
+ assert.strictEqual(
+ ar.content.pages[0].background.assetId,
+ br.content.pages[0].background.assetId
+ );
+ assert.notStrictEqual(
+ ar.content.pages[0].background.assetId,
+ cr.content.pages[0].background.assetId
+ );
+ assets.commitTokens([...ar.tokens, ...br.tokens, ...cr.tokens]);
+ assert.strictEqual(assets.cleanup([ar.content.pages[0].background.assetId]), 1);
+ assert.doesNotThrow(() => assets.readAsset(ar.content.pages[0].background.assetId));
+ assert.throws(() => assets.readAsset(cr.content.pages[0].background.assetId));
+});
+
+test('PDF 底版拒绝伪造文件、非法资源 ID 和不存在资源', () => {
+ const { root, assets } = fresh();
+ const fake = path.join(root, 'fake.pdf');
+ fs.writeFileSync(fake, 'not a pdf');
+ assert.throws(() => assets.stagePdf(fake, 1), /不是有效 PDF/);
+ assert.throws(() => assets.readAsset('../escape'), /资源标识无效/);
+ assert.throws(() => assets.readAsset(`pdf_${'f'.repeat(64)}`));
+});
diff --git a/src/_test/range-sessions.test.js b/src/_test/range-sessions.test.js
new file mode 100644
index 0000000..5298b5b
--- /dev/null
+++ b/src/_test/range-sessions.test.js
@@ -0,0 +1,177 @@
+const assert = require('node:assert');
+const test = require('node:test');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const modulePath = require.resolve('../reader/range-sessions');
+const dirs = [];
+
+function fixture() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-range-sessions-'));
+ dirs.push(dir);
+ const file = path.join(dir, 'fixture.pdf');
+ const bytes = Buffer.alloc(6 * 1024 * 1024);
+ for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251;
+ fs.writeFileSync(file, bytes);
+ delete require.cache[modulePath];
+ const sessions = require(modulePath);
+ sessions.init((_entryId, fileIndex) => ({
+ abs: file,
+ format: 'pdf',
+ fileIndex: Number.isInteger(fileIndex) ? fileIndex : 0
+ }));
+ return { sessions, file, bytes };
+}
+
+test.after(async () => {
+ try {
+ const sessions = require(modulePath);
+ await sessions.closeAll();
+ } catch (error) { /* ignore */ }
+ for (const dir of dirs) {
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (error) { /* ignore */ }
+ }
+});
+
+test('PDF 分段会话只返回请求范围并可显式关闭', async () => {
+ const { sessions, bytes } = fixture();
+ const opened = await sessions.open(10, 'entry', 0);
+ assert.match(opened.sessionId, /^[a-f0-9-]{36}$/);
+ assert.strictEqual(opened.size, bytes.length);
+ assert.strictEqual(opened.chunkSize, sessions.RANGE_CHUNK_BYTES);
+ const begin = 1024 * 1024 + 137;
+ const end = begin + 256 * 1024;
+ const result = await sessions.read(10, opened.sessionId, begin, end);
+ assert.deepStrictEqual(result, bytes.subarray(begin, end));
+ assert.strictEqual(await sessions.close(10, opened.sessionId), true);
+ assert.strictEqual(await sessions.close(10, opened.sessionId), false);
+ assert.strictEqual(sessions.status().sessions, 0);
+});
+
+test('PDF 分段会话绑定发送者并限制范围与并发会话数量', async () => {
+ const { sessions, bytes } = fixture();
+ const first = await sessions.open(20, 'entry', 0);
+ await assert.rejects(() => sessions.read(21, first.sessionId, 0, 1024), /无效或已关闭/);
+ await assert.rejects(() => sessions.read(20, first.sessionId, -1, 1024), /范围无效/);
+ await assert.rejects(
+ () => sessions.read(20, first.sessionId, 0, sessions.MAX_RANGE_BYTES + 1),
+ /不能超过 4 MB/
+ );
+ await assert.rejects(
+ () => sessions.read(20, first.sessionId, bytes.length - 10, bytes.length + 1),
+ /范围无效/
+ );
+
+ const ids = [first.sessionId];
+ for (let index = 0; index < sessions.MAX_SESSIONS_PER_SENDER; index++) {
+ ids.push((await sessions.open(20, 'entry', 0)).sessionId);
+ }
+ assert.strictEqual(sessions.status().sessions, sessions.MAX_SESSIONS_PER_SENDER);
+ await assert.rejects(() => sessions.read(20, ids[0], 0, 1024), /无效或已关闭/);
+ assert.strictEqual(await sessions.closeSender(20), sessions.MAX_SESSIONS_PER_SENDER);
+ assert.strictEqual(sessions.status().sessions, 0);
+});
+
+test('PDF 在阅读期间发生变化时拒绝继续提供旧会话数据', async () => {
+ const { sessions, file } = fixture();
+ const opened = await sessions.open(30, 'entry', 0);
+ fs.appendFileSync(file, Buffer.from([1]));
+ await assert.rejects(
+ () => sessions.read(30, opened.sessionId, 0, 1024),
+ /发生变化/
+ );
+ await sessions.closeSender(30);
+});
+
+test('40 GB 文件使用安全整数偏移按需读取而不分配整文件缓冲区', async () => {
+ delete require.cache[modulePath];
+ const sessions = require(modulePath);
+ const size = 40 * 1024 * 1024 * 1024;
+ let closed = false;
+ const stat = { size, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
+ const handle = {
+ stat: async () => stat,
+ read: async (buffer, offset, length, position) => {
+ for (let index = 0; index < length; index++) {
+ buffer[offset + index] = (position + index) % 251;
+ }
+ return { bytesRead: length, buffer };
+ },
+ close: async () => { closed = true; }
+ };
+ sessions.init(
+ () => ({ abs: 'virtual-40gb.pdf', format: 'pdf', fileIndex: 0 }),
+ { promises: { open: async () => handle } }
+ );
+ const opened = await sessions.open(40, 'huge', 0);
+ assert.strictEqual(opened.size, size);
+ const begin = size - 8192;
+ const result = await sessions.read(40, opened.sessionId, begin, begin + 4096);
+ assert.strictEqual(result.length, 4096);
+ assert.strictEqual(result[0], begin % 251);
+ assert.strictEqual(result[4095], (begin + 4095) % 251);
+ await sessions.closeSender(40);
+ assert.strictEqual(closed, true);
+});
+
+test('发送者销毁与会话创建竞态不会遗留文件句柄', async () => {
+ delete require.cache[modulePath];
+ const sessions = require(modulePath);
+ const stat = { size: 4096, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
+ let releaseOpen;
+ let closed = false;
+ sessions.init(
+ () => ({ abs: 'delayed.pdf', format: 'pdf', fileIndex: 0 }),
+ {
+ promises: {
+ open: () => new Promise((resolve) => {
+ releaseOpen = () => resolve({
+ stat: async () => stat,
+ read: async () => ({ bytesRead: 0 }),
+ close: async () => { closed = true; }
+ });
+ })
+ }
+ }
+ );
+ const opening = sessions.open(50, 'entry', 0);
+ while (!releaseOpen) await new Promise((resolve) => setImmediate(resolve));
+ await sessions.closeSender(50);
+ releaseOpen();
+ await assert.rejects(opening, /窗口已关闭/);
+ assert.strictEqual(closed, true);
+ assert.strictEqual(sessions.status().sessions, 0);
+});
+
+test('范围读取完成后再次校验文件签名', async () => {
+ delete require.cache[modulePath];
+ const sessions = require(modulePath);
+ let changed = false;
+ let closed = false;
+ const handle = {
+ stat: async () => ({
+ size: 4096,
+ mtimeMs: changed ? 2 : 1,
+ ctimeMs: 1,
+ isFile: () => true
+ }),
+ read: async (buffer, offset, length) => {
+ buffer.fill(1, offset, offset + length);
+ changed = true;
+ return { bytesRead: length, buffer };
+ },
+ close: async () => { closed = true; }
+ };
+ sessions.init(
+ () => ({ abs: 'changing.pdf', format: 'pdf', fileIndex: 0 }),
+ { promises: { open: async () => handle } }
+ );
+ const opened = await sessions.open(60, 'entry', 0);
+ await assert.rejects(
+ () => sessions.read(60, opened.sessionId, 0, 1024),
+ /发生变化/
+ );
+ while (!closed) await new Promise((resolve) => setImmediate(resolve));
+ assert.strictEqual(sessions.status().sessions, 0);
+});
diff --git a/src/_test/reader-window.test.js b/src/_test/reader-window.test.js
new file mode 100644
index 0000000..0a548f2
--- /dev/null
+++ b/src/_test/reader-window.test.js
@@ -0,0 +1,100 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const EventEmitter = require('node:events');
+const Module = require('node:module');
+
+test('阅读器全局只创建一个窗口,新书与删除请求路由到标签事件', () => {
+ const instances = [];
+ let webContentsId = 0;
+
+ class FakeWindow extends EventEmitter {
+ constructor(options) {
+ super();
+ this.options = options;
+ this.destroyed = false;
+ this.focused = 0;
+ this.webContents = new EventEmitter();
+ this.webContents.id = ++webContentsId;
+ this.webContents.loading = true;
+ this.webContents.sent = [];
+ this.webContents.setWindowOpenHandler = (handler) => { this.webContents.windowOpenHandler = handler; };
+ this.webContents.isLoadingMainFrame = () => this.webContents.loading;
+ this.webContents.send = (channel, payload) => this.webContents.sent.push([channel, payload]);
+ instances.push(this);
+ }
+ loadFile(file, options) {
+ this.loaded = { file, options };
+ }
+ isDestroyed() { return this.destroyed; }
+ isMinimized() { return false; }
+ focus() { this.focused += 1; }
+ close() {
+ const event = { prevented: false, preventDefault() { this.prevented = true; } };
+ this.emit('close', event);
+ if (!event.prevented) {
+ this.destroyed = true;
+ this.emit('closed');
+ }
+ }
+ destroy() {
+ this.destroyed = true;
+ this.emit('closed');
+ }
+ }
+
+ const originalLoad = Module._load;
+ Module._load = function mock(request, parent, isMain) {
+ if (request === 'electron') return { BrowserWindow: FakeWindow };
+ return originalLoad.call(this, request, parent, isMain);
+ };
+ const modulePath = require.resolve('../reader/window.js');
+ delete require.cache[modulePath];
+ let windows;
+ try {
+ windows = require(modulePath);
+ } finally {
+ Module._load = originalLoad;
+ }
+
+ const firstLocator = { kind: 'pdf', page: 4 };
+ const secondLocator = { kind: 'epub', chapter: 2, offset: 180 };
+ const first = windows.open('book-a', 'C:\\app', 0, firstLocator);
+ const second = windows.open('book-b', 'C:\\app', 1, secondLocator);
+ assert.strictEqual(first, second);
+ assert.strictEqual(instances.length, 1);
+ assert.deepStrictEqual(first.loaded.options.query, {
+ entryId: 'book-a',
+ fileIndex: '0',
+ locator: JSON.stringify(firstLocator)
+ });
+ assert.deepStrictEqual(first.webContents.sent, [], '加载完成前不应丢失事件或过早发送');
+ assert.deepStrictEqual(first.webContents.windowOpenHandler(), { action: 'deny' });
+ const navigation = { prevented: false, preventDefault() { this.prevented = true; } };
+ first.webContents.emit('will-navigate', navigation, 'https://untrusted.example/');
+ assert.strictEqual(navigation.prevented, true);
+
+ first.webContents.loading = false;
+ assert.strictEqual(windows.markReady(first.webContents), true);
+ assert.strictEqual(windows.isReady(first), true);
+ assert.deepStrictEqual(first.webContents.sent[0], [
+ 'reader:openEntry',
+ { entryId: 'book-b', fileIndex: 1, locator: secondLocator }
+ ]);
+
+ windows.closeFor('book-a');
+ assert.deepStrictEqual(first.webContents.sent[1], ['reader:closeEntry', 'book-a']);
+ windows.purgeFor('book-b', 'purge-1');
+ assert.deepStrictEqual(first.webContents.sent[2], [
+ 'reader:purgeEntry',
+ { entryId: 'book-b', requestId: 'purge-1' }
+ ]);
+ assert.strictEqual(first.destroyed, false, '删除一个条目不应关闭整个阅读器窗口');
+ assert.strictEqual(windows.fromWebContents(first.webContents), 'reader');
+ assert.deepStrictEqual(windows.all(), [first]);
+
+ first.close();
+ assert.strictEqual(first.destroyed, false, '关闭前应等待渲染器排空保存队列');
+ assert.deepStrictEqual(first.webContents.sent[3], ['reader:prepareClose', null]);
+ assert.strictEqual(windows.shutdownReady(first.webContents), true);
+ assert.strictEqual(first.destroyed, true);
+});
diff --git a/src/_test/reader.test.js b/src/_test/reader.test.js
new file mode 100644
index 0000000..beb302c
--- /dev/null
+++ b/src/_test/reader.test.js
@@ -0,0 +1,979 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const h = require('./helpers');
+
+h.installFetchStub();
+
+const storePath = require.resolve('../reader/store.js');
+const cfgPath = require.resolve('../reader/ai-config.js');
+
+const dirs = [];
+function tmp() {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-reader-'));
+ dirs.push(d);
+ return d;
+}
+test.after(() => {
+ for (const d of dirs) {
+ try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
+ }
+});
+
+function freshStore() {
+ delete require.cache[storePath];
+ const s = require(storePath);
+ s.init(tmp());
+ return s;
+}
+function storeAt(dir) {
+ delete require.cache[storePath];
+ const s = require(storePath);
+ s.init(dir);
+ return s;
+}
+function fakeStorage(available = true) {
+ return {
+ isEncryptionAvailable: () => available,
+ encryptString: (s) => Buffer.from('ENC:' + Buffer.from(s, 'utf8').toString('base64')),
+ decryptString: (b) => {
+ const s = b.toString();
+ if (!s.startsWith('ENC:')) throw new Error('bad');
+ return Buffer.from(s.slice(4), 'base64').toString('utf8');
+ }
+ };
+}
+
+// --- reader/store ---
+
+test('阅读进度可存取,百分比被夹在 0..1', () => {
+ const s = freshStore();
+ assert.strictEqual(s.getLastReadAt('e1'), 0);
+ s.setProgress('e1', { kind: 'pdf', page: 5 }, 2.5);
+ const st = s.getState('e1');
+ assert.strictEqual(st.progress.locator.page, 5);
+ assert.strictEqual(st.progress.percent, 1);
+ assert.strictEqual(s.getLastReadAt('e1'), st.progress.at);
+ s.setProgress('e1', { kind: 'pdf', page: 1 }, -3);
+ assert.strictEqual(s.getState('e1').progress.percent, 0);
+});
+
+test('书签与笔记的增删互不干扰', () => {
+ const s = freshStore();
+ const b = s.addBookmark('e1', { locator: { kind: 'epub', chapter: 2, offset: 10 }, label: '第 3 章' });
+ const n = s.addNote('e1', { locator: { kind: 'epub', chapter: 2 }, text: '这是笔记', kind: 'ai' });
+ let st = s.getState('e1');
+ assert.strictEqual(st.bookmarks.length, 1);
+ assert.strictEqual(st.notes.length, 1);
+ assert.strictEqual(st.notes[0].kind, 'ai');
+
+ s.removeBookmark('e1', b.id);
+ st = s.getState('e1');
+ assert.strictEqual(st.bookmarks.length, 0);
+ assert.strictEqual(st.notes.length, 1, '删书签不该动笔记');
+ assert.strictEqual(s.removeNote('e1', n.id), true);
+});
+
+test('缺少定位信息的书签被拒绝', () => {
+ const s = freshStore();
+ assert.throws(() => s.addBookmark('e1', { label: 'x' }), /定位/);
+ assert.throws(() => s.addNote('e1', { text: ' ' }), /内容为空/);
+});
+
+test('不同条目的阅读数据互相隔离', () => {
+ const s = freshStore();
+ s.addBookmark('a', { locator: { kind: 'pdf', page: 1 } });
+ s.addBookmark('b', { locator: { kind: 'pdf', page: 2 } });
+ assert.strictEqual(s.getState('a').bookmarks.length, 1);
+ assert.strictEqual(s.getState('b').bookmarks[0].locator.page, 2);
+ s.forget('a');
+ assert.strictEqual(s.getState('a').bookmarks.length, 0);
+ assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
+});
+
+test('getState 返回副本,外部改动不污染存储', () => {
+ const s = freshStore();
+ s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } });
+ const st = s.getState('e1');
+ st.bookmarks.push({ id: 'fake' });
+ assert.strictEqual(s.getState('e1').bookmarks.length, 1);
+});
+
+test('损坏的 reader.json 不会导致崩溃', () => {
+ const d = tmp();
+ fs.writeFileSync(path.join(d, 'reader.json'), '{ 这不是 json');
+ delete require.cache[storePath];
+ const s = require(storePath);
+ s.init(d);
+ assert.deepStrictEqual(s.getState('x').bookmarks, []);
+ s.setProgress('x', { kind: 'pdf', page: 1 }, 0.1);
+ assert.ok(s.getState('x').progress);
+ assert.strictEqual(
+ fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')),
+ true,
+ '损坏原文件应被隔离保留'
+ );
+ assert.doesNotThrow(() => JSON.parse(fs.readFileSync(path.join(d, 'reader.json'), 'utf8')));
+});
+
+test('同一条目的进度、书签和笔记按文档标识隔离', () => {
+ const s = freshStore();
+ s.setProgress('e1', 'doc-a', { kind: 'pdf', page: 2 }, 0.2);
+ s.setProgress('e1', 'doc-b', { kind: 'epub', chapter: 3, offset: 20 }, 0.7);
+ s.addBookmark('e1', {
+ documentKey: 'doc-a',
+ locator: { kind: 'pdf', page: 2 },
+ label: 'PDF'
+ });
+ s.addBookmark('e1', {
+ documentKey: 'doc-b',
+ locator: { kind: 'epub', chapter: 3, offset: 20 },
+ label: 'EPUB'
+ });
+ s.addNote('e1', { documentKey: 'doc-a', text: 'PDF 笔记' });
+ s.addNote('e1', { documentKey: 'doc-b', text: 'EPUB 笔记' });
+ assert.strictEqual(s.getState('e1', 'doc-a').progress.locator.page, 2);
+ assert.strictEqual(s.getState('e1', 'doc-b').progress.locator.chapter, 3);
+ assert.deepStrictEqual(s.getState('e1', 'doc-a').bookmarks.map((item) => item.label), ['PDF']);
+ assert.deepStrictEqual(s.getState('e1', 'doc-b').notes.map((item) => item.text), ['EPUB 笔记']);
+});
+
+test('主文件损坏时隔离原件并从有效备份恢复阅读资料', () => {
+ const d = tmp();
+ const file = path.join(d, 'reader.json');
+ fs.writeFileSync(file, '{ broken');
+ fs.writeFileSync(`${file}.bak`, JSON.stringify({
+ version: 2,
+ collections: [],
+ entries: {
+ restored: {
+ progress: null,
+ bookmarks: [],
+ notes: [{ id: 'note-1', text: '已恢复', kind: 'user', at: 10 }]
+ }
+ }
+ }));
+ const s = storeAt(d);
+ assert.strictEqual(s.getState('restored').notes[0].text, '已恢复');
+ assert.strictEqual(fs.existsSync(file), true);
+ assert.strictEqual(
+ fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')),
+ true
+ );
+});
+
+test('迁移会丢弃合法 JSON 中结构损坏的进度和书签', () => {
+ const d = tmp();
+ fs.writeFileSync(path.join(d, 'reader.json'), JSON.stringify({
+ version: 2,
+ collections: [],
+ entries: {
+ broken: {
+ progress: { locator: null, percent: 2 },
+ progressByDocument: {
+ bad: { locator: null },
+ good: { locator: { kind: 'pdf', page: 2 }, percent: 2 }
+ },
+ bookmarks: [
+ null,
+ { id: 'bad', locator: null },
+ { id: 'good', locator: { kind: 'pdf', page: 3 }, label: '有效书签' }
+ ],
+ notes: []
+ }
+ }
+ }));
+ const s = storeAt(d);
+ assert.deepStrictEqual(s.getState('broken').bookmarks.map((item) => item.label), ['有效书签']);
+ assert.doesNotThrow(() => s.bindDocument('broken', 'doc-current'));
+ const state = s.getState('broken', 'good');
+ assert.strictEqual(state.progress.percent, 1);
+});
+
+test('首次绑定文档把旧版进度和书签安全迁移到该文档', () => {
+ const s = freshStore();
+ s.setProgress('legacy', { kind: 'pdf', page: 6 }, 0.5);
+ s.addBookmark('legacy', { locator: { kind: 'pdf', page: 6 }, label: '旧书签' });
+ assert.strictEqual(s.bindDocument('legacy', 'doc-key'), true);
+ const state = s.getState('legacy', 'doc-key');
+ assert.strictEqual(state.progress.locator.page, 6);
+ assert.strictEqual(state.bookmarks[0].documentKey, 'doc-key');
+});
+
+test('结构化笔记支持纯引用、来源、标签和上下文字段', () => {
+ const s = freshStore();
+ const note = s.addNote('e1', {
+ title: '重点',
+ quote: '只保存引用也可以',
+ context: '第二章',
+ source: 'selection',
+ documentKey: 'doc-1',
+ fileIndex: 2,
+ tags: ['方法', '方法', '研究'],
+ pinned: true,
+ locator: { kind: 'pdf', page: 3 }
+ });
+ assert.strictEqual(note.text, '');
+ assert.strictEqual(note.source, 'selection');
+ assert.strictEqual(note.kind, 'user', '保留旧渲染器使用的兼容别名');
+ assert.deepStrictEqual(note.tags, ['方法', '研究']);
+ assert.strictEqual(note.createdAt, note.updatedAt);
+
+ note.tags.push('外部修改');
+ note.locator.page = 99;
+ const stored = s.getState('e1').notes[0];
+ assert.deepStrictEqual(stored.tags, ['方法', '研究']);
+ assert.strictEqual(stored.locator.page, 3);
+});
+
+test('富文本笔记保存格式、纯文本索引和内嵌图片', () => {
+ const s = freshStore();
+ const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
+ const richContent = {
+ version: 1,
+ blocks: [
+ {
+ type: 'text',
+ style: 'heading1',
+ runs: [{ text: '富文本标题', bold: true }]
+ },
+ {
+ type: 'text',
+ style: 'paragraph',
+ runs: [{ text: '正文' }, { text: '强调', italic: true, underline: true }]
+ },
+ { type: 'image', dataUrl: image, alt: '示例图片' }
+ ]
+ };
+ const note = s.addNote('e1', { richContent, source: 'manual' });
+ assert.strictEqual(note.noteType, 'reading');
+ assert.strictEqual(note.text, '富文本标题\n正文强调');
+ assert.deepStrictEqual(note.richContent, {
+ version: 2,
+ ops: [
+ { insert: '富文本标题', attributes: { bold: true } },
+ { insert: '\n', attributes: { header: 1 } },
+ { insert: '正文' },
+ { insert: '强调', attributes: { italic: true, underline: true } },
+ { insert: '\n' },
+ { insert: { image } }
+ ]
+ });
+ assert.strictEqual(s.listNotes({ query: '正文强调' })[0].id, note.id);
+
+ const stored = s.getState('e1').notes[0];
+ assert.strictEqual(stored.richContent.version, 2);
+ richContent.blocks[0].runs[0].text = '外部污染';
+ assert.strictEqual(s.getState('e1').notes[0].text, '富文本标题\n正文强调');
+
+ const imageOnly = s.addNote('e1', {
+ richContent: { version: 1, blocks: [{ type: 'image', dataUrl: image, alt: '' }] }
+ });
+ assert.strictEqual(imageOnly.text, '');
+ assert.strictEqual(imageOnly.richContent.ops[0].insert.image, image);
+});
+
+test('富文本笔记拒绝主动内容、远程图片和超限结构', () => {
+ const s = freshStore();
+ assert.throws(
+ () => s.addNote('e1', {
+ richContent: {
+ version: 1,
+ blocks: [{ type: 'image', dataUrl: 'https://example.com/x.png', alt: '' }]
+ }
+ }),
+ /图片格式无效/
+ );
+ assert.throws(
+ () => s.addNote('e1', {
+ richContent: {
+ version: 1,
+ blocks: [{ type: 'html', html: '' }]
+ }
+ }),
+ /段落无效/
+ );
+ assert.throws(
+ () => s.addNote('e1', {
+ richContent: {
+ version: 1,
+ blocks: Array.from({ length: 501 }, () => ({
+ type: 'text', style: 'paragraph', runs: [{ text: 'x' }]
+ }))
+ }
+ }),
+ /内容过多/
+ );
+});
+
+test('Quill Delta 仅保留受支持格式并拒绝主动嵌入', () => {
+ const s = freshStore();
+ const note = s.addNote('e1', {
+ richContent: {
+ version: 2,
+ ops: [
+ { insert: '一级标题' },
+ { insert: '\n', attributes: { header: 1 } },
+ { insert: '正文', attributes: { bold: true, italic: true } },
+ { insert: '\n', attributes: { list: 'bullet' } },
+ { insert: '代码' },
+ { insert: '\n', attributes: { 'code-block': 'plain' } }
+ ]
+ }
+ });
+ assert.strictEqual(note.text, '一级标题\n正文\n代码');
+ assert.strictEqual(note.richContent.version, 2);
+ assert.deepStrictEqual(
+ note.richContent.ops.at(-1),
+ { insert: '\n', attributes: { 'code-block': 'plain' } }
+ );
+ assert.throws(() => s.addNote('e1', {
+ richContent: {
+ version: 2,
+ ops: [{ insert: '外链', attributes: { link: 'https://example.com' } }]
+ }
+ }), /不支持的格式/);
+ assert.throws(() => s.addNote('e1', {
+ richContent: {
+ version: 2,
+ ops: [{ insert: { video: 'https://example.com/video' } }]
+ }
+ }), /嵌入内容无效/);
+ assert.throws(() => s.addNote('e1', {
+ richContent: {
+ version: 2,
+ ops: [{ retain: 1, attributes: { bold: true } }]
+ }
+ }), /操作无效/);
+});
+
+test('画布笔记保存分页画布、PDF 底版并支持类型筛选和文本搜索', () => {
+ const s = freshStore();
+ const assetId = `pdf_${'a'.repeat(64)}`;
+ const canvasContent = {
+ version: 2,
+ flow: {
+ version: 1,
+ ops: [
+ { insert: '全局文本关键词', attributes: { bold: true } },
+ { insert: '\n' },
+ { insert: { canvasPageBreak: 'pg_two' } }
+ ]
+ },
+ pages: [
+ {
+ id: 'pg_one',
+ width: 794,
+ height: 1123,
+ background: { type: 'template', template: 'grid' },
+ objects: [{
+ type: 'IText',
+ canvasKind: 'text',
+ text: '画布关键词',
+ left: 10,
+ top: 20,
+ fill: '#222222',
+ fontSize: 18
+ }]
+ },
+ {
+ id: 'pg_two',
+ width: 612,
+ height: 792,
+ background: { type: 'pdf', assetId, page: 2 },
+ objects: []
+ }
+ ]
+ };
+ const note = s.addStandaloneNote({
+ noteType: 'canvas',
+ canvasContent
+ });
+ assert.strictEqual(note.noteType, 'canvas');
+ assert.strictEqual(note.text, '全局文本关键词\n画布关键词');
+ assert.deepStrictEqual(note.canvasContent, canvasContent);
+ assert.deepStrictEqual(s.noteAssetIds(), [assetId]);
+ assert.strictEqual(s.listNotes({ query: '画布关键词' })[0].id, note.id);
+ assert.strictEqual(s.listNotes({ query: '全局文本关键词' })[0].id, note.id);
+ assert.deepStrictEqual(s.listNotes({ noteType: 'canvas' }).map((item) => item.id), [note.id]);
+ assert.deepStrictEqual(s.listNotes({ noteType: 'reading' }), []);
+ assert.strictEqual(s.removeNote(s.STANDALONE_ENTRY_ID, note.id), true);
+ assert.deepStrictEqual(s.noteAssetIds(), []);
+});
+
+test('读书笔记和画布笔记创建后保持独立且类型不可更改', () => {
+ const s = freshStore();
+ const canvasContent = {
+ version: 1,
+ pages: [{
+ id: 'pg_type',
+ width: 794,
+ height: 1123,
+ background: { type: 'template', template: 'blank' },
+ objects: []
+ }]
+ };
+ assert.throws(() => s.addNote('e1', {
+ noteType: 'unknown',
+ text: '正文'
+ }), /笔记类型无效/);
+ assert.throws(() => s.addNote('e1', {
+ noteType: 'reading',
+ richContent: { version: 2, ops: [{ insert: '正文\n' }] },
+ canvasContent
+ }), /读书笔记不能包含画布内容/);
+ assert.throws(() => s.addNote('e1', {
+ noteType: 'canvas',
+ richContent: { version: 2, ops: [{ insert: '正文\n' }] },
+ canvasContent
+ }), /画布笔记不能包含富文本内容/);
+ const reading = s.addNote('e1', { noteType: 'reading', text: '正文' });
+ assert.throws(() => s.updateNote('e1', reading.id, { noteType: 'canvas' }), /不能更改/);
+ const canvas = s.addNote('e1', { noteType: 'canvas', canvasContent });
+ assert.throws(() => s.updateNote('e1', canvas.id, {
+ richContent: { version: 2, ops: [{ insert: '正文\n' }] }
+ }), /不能包含富文本内容/);
+});
+
+test('v4 混合笔记迁移为画布笔记并保留两类旧内容', () => {
+ const root = tmp();
+ fs.writeFileSync(path.join(root, 'reader.json'), JSON.stringify({
+ version: 4,
+ collections: [],
+ entries: {
+ e1: {
+ notes: [{
+ id: 'nt_legacy_mixed',
+ title: '旧混合笔记',
+ richContent: { version: 2, ops: [{ insert: '旧正文\n' }] },
+ canvasContent: {
+ version: 1,
+ pages: [{
+ id: 'pg_legacy',
+ width: 794,
+ height: 1123,
+ background: { type: 'template', template: 'grid' },
+ objects: []
+ }]
+ },
+ source: 'manual',
+ tags: [],
+ createdAt: 1,
+ updatedAt: 1
+ }, {
+ id: 'nt_legacy_blank_canvas',
+ richContent: { version: 2, ops: [{ insert: '仅正文\n' }] },
+ canvasContent: {
+ version: 1,
+ pages: [{
+ id: 'pg_legacy_blank',
+ width: 794,
+ height: 1123,
+ background: { type: 'template', template: 'blank' },
+ objects: []
+ }]
+ },
+ source: 'manual',
+ tags: [],
+ createdAt: 2,
+ updatedAt: 2
+ }]
+ }
+ }
+ }));
+ const s = storeAt(root);
+ const notes = s.getState('e1').notes;
+ const note = notes.find((item) => item.id === 'nt_legacy_mixed');
+ assert.strictEqual(note.noteType, 'canvas');
+ assert.strictEqual(note.richContent.ops[0].insert, '旧正文\n');
+ assert.strictEqual(note.canvasContent.version, 2);
+ assert.strictEqual(note.canvasContent.pages[0].background.template, 'grid');
+ const updated = s.updateNote('e1', note.id, {
+ noteType: 'canvas',
+ canvasContent: note.canvasContent
+ });
+ assert.strictEqual(updated.richContent.ops[0].insert, '旧正文\n');
+ const blankCanvas = notes.find((item) => item.id === 'nt_legacy_blank_canvas');
+ assert.strictEqual(blankCanvas.noteType, 'reading');
+ assert.strictEqual(blankCanvas.richContent.ops[0].insert, '仅正文\n');
+ assert.strictEqual(blankCanvas.canvasContent.pages[0].background.template, 'blank');
+});
+
+test('画布笔记拒绝未知对象、主动属性、远程图片和非法 PDF 引用', () => {
+ const s = freshStore();
+ const page = (object, background = { type: 'template', template: 'blank' }) => ({
+ version: 1,
+ pages: [{
+ id: 'pg_safe',
+ width: 794,
+ height: 1123,
+ background,
+ objects: object ? [object] : []
+ }]
+ });
+ assert.throws(() => s.addNote('e1', {
+ canvasContent: page({ type: 'Circle', canvasKind: 'circle' })
+ }), /对象类型无效/);
+ assert.throws(() => s.addNote('e1', {
+ canvasContent: page({
+ type: 'Path',
+ canvasKind: 'pen',
+ path: [['M', 0, 0], ['L', 1, 1]],
+ clipPath: {}
+ })
+ }), /不支持的属性/);
+ assert.throws(() => s.addNote('e1', {
+ canvasContent: page({
+ type: 'Path',
+ canvasKind: 'pen',
+ path: [['M', 0, 0]],
+ arbitraryPayload: { type: 'Image' }
+ })
+ }), /不支持的属性/);
+ assert.throws(() => s.addNote('e1', {
+ canvasContent: page({
+ type: 'Path',
+ canvasKind: 'pen',
+ path: [['M', 0, 0]],
+ scaleX: 1000
+ })
+ }), /缩放无效/);
+ assert.throws(() => s.addNote('e1', {
+ canvasContent: page({
+ type: 'Image',
+ canvasKind: 'image',
+ src: 'https://example.com/image.png'
+ })
+ }), /图片格式无效/);
+ assert.throws(() => s.addNote('e1', {
+ canvasContent: page(null, { type: 'pdf', assetId: '../outside', page: 1 })
+ }), /PDF 底版资源无效/);
+ const flowPage = {
+ version: 2,
+ pages: [{
+ id: 'pg_flow',
+ width: 794,
+ height: 1123,
+ background: { type: 'template', template: 'blank' },
+ objects: []
+ }, {
+ id: 'pg_flow_two',
+ width: 794,
+ height: 1123,
+ background: { type: 'template', template: 'blank' },
+ objects: [],
+ flowAuto: true
+ }]
+ };
+ assert.throws(() => s.addNote('e1', {
+ noteType: 'canvas',
+ canvasContent: {
+ ...flowPage,
+ flow: { version: 1, ops: [{ insert: { canvasPageBreak: '../outside' } }] }
+ }
+ }), /分页符无效/);
+ assert.throws(() => s.addNote('e1', {
+ noteType: 'canvas',
+ canvasContent: {
+ ...flowPage,
+ flow: { version: 1, ops: [{ insert: { image: 'https://example.com/a.png' } }] }
+ }
+ }), /嵌入内容无效/);
+ const flowNote = s.addNote('e1', {
+ noteType: 'canvas',
+ canvasContent: {
+ ...flowPage,
+ flow: {
+ version: 1,
+ ops: [
+ { insert: '跨页正文\n', attributes: { header: 1 } },
+ { insert: { canvasPageBreak: 'pg_flow_two' } },
+ { insert: '第二页正文\n' }
+ ]
+ }
+ }
+ });
+ assert.strictEqual(flowNote.text, '跨页正文\n第二页正文');
+ assert.strictEqual(flowNote.canvasContent.pages[1].flowAuto, true);
+});
+
+test('笔记可更新且必需保留正文或引用', () => {
+ const s = freshStore();
+ const note = s.addNote('e1', { text: '原文', source: 'manual' });
+ const updated = s.updateNote('e1', note.id, {
+ text: '',
+ quote: '新引用',
+ source: 'ai',
+ aiTask: '总结',
+ tags: ['AI'],
+ pinned: true
+ });
+ assert.strictEqual(updated.quote, '新引用');
+ assert.strictEqual(updated.source, 'ai');
+ assert.strictEqual(updated.kind, 'ai');
+ assert.strictEqual(updated.aiTask, '总结');
+ assert.strictEqual(updated.at, updated.updatedAt);
+ assert.throws(
+ () => s.updateNote('e1', note.id, { quote: '', text: '' }),
+ /内容为空/
+ );
+ assert.strictEqual(s.getState('e1').notes[0].quote, '新引用', '失败更新必须回滚');
+ assert.strictEqual(s.updateNote('e1', 'nt_missing', { title: 'x' }), null);
+});
+
+test('笔记本名称不区分大小写去重,删除后笔记移入未分类', () => {
+ const s = freshStore();
+ const collection = s.addCollection({ name: 'Research' });
+ assert.throws(() => s.addCollection({ name: ' research ' }), /已存在/);
+ const note = s.addNote('e1', { text: '归档笔记', collectionId: collection.id });
+ assert.strictEqual(s.listCollections()[0].name, 'Research');
+ assert.strictEqual(s.updateCollection(collection.id, { name: 'Inbox' }).name, 'Inbox');
+ assert.strictEqual(s.removeCollection(collection.id), true);
+ assert.strictEqual(s.listCollections().length, 0);
+ assert.strictEqual(s.getState('e1').notes[0].id, note.id);
+ assert.strictEqual(s.getState('e1').notes[0].collectionId, null);
+ assert.strictEqual(s.listNotes({ collectionId: null }).length, 1);
+});
+
+test('聚合笔记按置顶与更新时间排序并支持全部筛选', () => {
+ const s = freshStore();
+ const work = s.addCollection('Work');
+ s.setBookSnapshot('book-a', { title: 'Alpha Handbook', authors: ['A. One'] });
+ s.setBookSnapshot('book-b', { title: 'Beta Notes', authors: ['B. Two'] });
+ const first = s.addNote('book-a', {
+ title: 'Ordinary',
+ text: 'needle in text',
+ source: 'manual',
+ tags: ['Blue'],
+ collectionId: work.id
+ });
+ const pinned = s.addNote('book-b', {
+ quote: 'selected passage',
+ source: 'selection',
+ tags: ['Green'],
+ pinned: true
+ });
+ s.updateNote('book-a', first.id, { context: 'changed' });
+
+ const all = s.listNotes();
+ assert.deepStrictEqual(all.map((note) => note.id), [pinned.id, first.id]);
+ assert.strictEqual(all[0].entryId, 'book-b');
+ assert.strictEqual(all[0].bookSnapshot.title, 'Beta Notes');
+ assert.deepStrictEqual(s.listNotes({ entryId: 'book-a' }).map((n) => n.id), [first.id]);
+ assert.deepStrictEqual(s.listNotes({ collectionId: work.id }).map((n) => n.id), [first.id]);
+ assert.deepStrictEqual(s.listNotes({ source: 'selection' }).map((n) => n.id), [pinned.id]);
+ assert.deepStrictEqual(s.listNotes({ tag: 'blue' }).map((n) => n.id), [first.id]);
+ assert.deepStrictEqual(s.listNotes({ query: 'alpha hand' }).map((n) => n.id), [first.id]);
+ assert.deepStrictEqual(s.listNotes({ query: 'NEEDLE' }).map((n) => n.id), [first.id]);
+ assert.deepStrictEqual(s.getNoteCounts(), { 'book-a': 1, 'book-b': 1 });
+
+ all[0].bookSnapshot.title = '污染';
+ all[0].tags.push('污染');
+ assert.strictEqual(s.listNotes()[0].bookSnapshot.title, 'Beta Notes');
+ assert.deepStrictEqual(s.listNotes()[0].tags, ['Green']);
+});
+
+
+test('无关联笔记独立持久化且不计入书库卡片笔记数', () => {
+ const s = freshStore();
+ const note = s.addStandaloneNote({
+ title: '独立想法',
+ text: '不关联任何书籍',
+ source: 'manual',
+ tags: ['随想']
+ });
+ const listed = s.listNotes().find((item) => item.id === note.id);
+ assert.strictEqual(listed.entryId, s.STANDALONE_ENTRY_ID);
+ assert.strictEqual(listed.associated, false);
+ assert.strictEqual(listed.bookSnapshot, null);
+ assert.deepStrictEqual(s.getNoteCounts(), {});
+ assert.strictEqual(
+ s.updateNote(listed.entryId, note.id, { text: '已编辑' }).text,
+ '已编辑'
+ );
+ assert.strictEqual(s.removeNote(listed.entryId, note.id), true);
+});
+test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
+ const d = tmp();
+ const file = path.join(d, 'reader.json');
+ const legacy = {
+ entries: {
+ e1: {
+ progress: { locator: { page: 8 }, percent: 0.4, at: 10 },
+ bookmarks: [{ id: 'bm_old', locator: { page: 8 }, at: 11 }],
+ notes: [{
+ id: 'nt_old',
+ text: '旧笔记',
+ quote: '旧引用',
+ kind: 'ai',
+ at: 123,
+ locator: { page: 8 }
+ }]
+ }
+ }
+ };
+ fs.writeFileSync(file, JSON.stringify(legacy), 'utf8');
+ let s = storeAt(d);
+ const state = s.getState('e1');
+ assert.deepStrictEqual(state.progress, legacy.entries.e1.progress);
+ assert.deepStrictEqual(state.bookmarks, legacy.entries.e1.bookmarks);
+ assert.strictEqual(state.notes[0].id, 'nt_old');
+ assert.strictEqual(state.notes[0].source, 'ai');
+ assert.strictEqual(state.notes[0].createdAt, 123);
+ assert.strictEqual(state.notes[0].updatedAt, 123);
+ assert.strictEqual(state.notes[0].collectionId, null);
+
+ const migrated = JSON.parse(fs.readFileSync(file, 'utf8'));
+ assert.strictEqual(migrated.version, 6);
+ assert.deepStrictEqual(migrated.collections, []);
+ const bytes = fs.readFileSync(file, 'utf8');
+ s = storeAt(d);
+ assert.strictEqual(s.getState('e1').notes.length, 1);
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes, 'v6 再加载不应重复迁移');
+});
+
+test('字段限制、来源校验和安全 ID 校验生效', () => {
+ const s = freshStore();
+ assert.throws(() => s.getState('../reader'), /ID无效/);
+ assert.throws(
+ () => s.setProgress('e1', 'toString', { kind: 'pdf', page: 1 }, 0.1),
+ /文档标识无效/
+ );
+ assert.throws(() => s.addNote('e1', { text: 'x', source: 'robot' }), /来源无效/);
+ assert.throws(
+ () => s.addNote('e1', { text: 'x', collectionId: 'col_missing' }),
+ /不存在/
+ );
+ const note = s.addNote('e1', {
+ text: 'x'.repeat(25000),
+ tags: Array.from({ length: 40 }, (_, i) => `tag-${i}`)
+ });
+ assert.strictEqual(note.text.length, 20000);
+ assert.strictEqual(note.tags.length, 30);
+});
+
+test('写盘失败时内存和磁盘状态都回滚', () => {
+ const d = tmp();
+ let s = storeAt(d);
+ s.addNote('e1', { text: '已保存' });
+ const file = path.join(d, 'reader.json');
+ const before = fs.readFileSync(file, 'utf8');
+ const originalRename = fs.renameSync;
+ fs.renameSync = (from, to) => {
+ if (from === `${file}.tmp` && to === file) throw new Error('模拟写盘失败');
+ return originalRename(from, to);
+ };
+ try {
+ assert.throws(() => s.addNote('e1', { text: '不应保存' }), /模拟写盘失败/);
+ } finally {
+ fs.renameSync = originalRename;
+ }
+ assert.strictEqual(s.getState('e1').notes.length, 1);
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), before);
+
+ s = storeAt(d);
+ assert.strictEqual(s.getState('e1').notes.length, 1);
+});
+
+// --- reader/ai-config ---
+
+function freshCfg(storage = fakeStorage()) {
+ delete require.cache[cfgPath];
+ const c = require(cfgPath);
+ c.init(tmp(), storage);
+ return c;
+}
+
+test('AI Key 加密落盘,磁盘无明文', () => {
+ const d = tmp();
+ delete require.cache[cfgPath];
+ const c = require(cfgPath);
+ c.init(d, fakeStorage());
+ c.save({ baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat', apiKey: 'sk-SECRET-123' });
+
+ for (const f of fs.readdirSync(d)) {
+ const content = fs.readFileSync(path.join(d, f)).toString();
+ assert.ok(!content.includes('sk-SECRET-123'), `${f} 出现明文 Key`);
+ }
+ assert.strictEqual(c.get().apiKey, 'sk-SECRET-123');
+ assert.strictEqual(c.status().hasKey, true);
+});
+
+test('只改模型时不传 apiKey,不会清掉已存的 Key', () => {
+ const c = freshCfg();
+ c.save({
+ protocol: 'anthropic',
+ baseUrl: 'https://api.openai.com/v1',
+ model: 'gpt-4o-mini',
+ apiKey: 'sk-keep',
+ vision: true
+ });
+ c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' });
+ assert.strictEqual(c.get().apiKey, 'sk-keep');
+ assert.strictEqual(c.get().model, 'gpt-4o');
+ assert.strictEqual(c.status().protocol, 'anthropic');
+ assert.strictEqual(c.status().vision, true);
+});
+
+test('AI 接口类型显式持久化,旧配置默认使用 Chat Completions', () => {
+ const d = tmp();
+ fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({
+ baseUrl: 'https://api.openai.com/v1',
+ model: 'legacy'
+ }));
+ delete require.cache[cfgPath];
+ const c = require(cfgPath);
+ c.init(d, fakeStorage());
+ assert.strictEqual(c.status().protocol, 'chat-completions');
+ c.save({
+ protocol: 'openai-responses',
+ baseUrl: 'https://api.openai.com/v1',
+ model: 'gpt-4.1'
+ });
+ assert.strictEqual(c.status().protocol, 'openai-responses');
+ assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).protocol, 'openai-responses');
+ assert.throws(
+ () => c.save({ protocol: 'unknown', baseUrl: 'https://api.openai.com/v1', model: 'm' }),
+ /接口类型/
+ );
+});
+
+test('AI 状态区分模型已配置、缺少 Key 与 Key 无法读取', () => {
+ const d = tmp();
+ delete require.cache[cfgPath];
+ let c = require(cfgPath);
+ c.init(d, fakeStorage());
+ let status = c.status();
+ assert.strictEqual(status.modelConfigured, false);
+ assert.strictEqual(status.ready, false);
+ assert.strictEqual(status.keyState, 'missing');
+
+ c.save({
+ protocol: 'anthropic',
+ baseUrl: 'https://api.anthropic.com/v1',
+ model: 'claude-sonnet',
+ vision: true
+ });
+ status = c.status();
+ assert.strictEqual(status.modelConfigured, true);
+ assert.strictEqual(status.ready, false);
+ assert.strictEqual(status.keyState, 'missing');
+
+ c.save({
+ protocol: 'anthropic',
+ baseUrl: 'https://api.anthropic.com/v1',
+ model: 'claude-sonnet',
+ apiKey: 'sk-anthropic'
+ });
+ assert.strictEqual(c.status().ready, true);
+
+ delete require.cache[cfgPath];
+ c = require(cfgPath);
+ c.init(d, {
+ isEncryptionAvailable: () => true,
+ decryptString: () => { throw new Error('cannot decrypt'); }
+ });
+ status = c.status();
+ assert.strictEqual(status.modelConfigured, true);
+ assert.strictEqual(status.hasKey, false);
+ assert.strictEqual(status.ready, false);
+ assert.strictEqual(status.keyState, 'unreadable');
+});
+
+test('图像输入能力必须显式配置并持久化', () => {
+ const d = tmp();
+ delete require.cache[cfgPath];
+ const c = require(cfgPath);
+ c.init(d, fakeStorage());
+ c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm' });
+ assert.strictEqual(c.status().vision, false);
+ c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', vision: true });
+ assert.strictEqual(c.status().vision, true);
+ assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).vision, true);
+});
+
+test('图像输入能力拒绝配置文件中的非布尔真值', () => {
+ const d = tmp();
+ fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({
+ baseUrl: 'https://api.openai.com/v1',
+ model: 'm',
+ vision: 'true'
+ }));
+ delete require.cache[cfgPath];
+ const c = require(cfgPath);
+ c.init(d, fakeStorage());
+ assert.strictEqual(c.status().vision, false);
+ assert.strictEqual(c.get().vision, false);
+});
+
+test('显式传空字符串才清除 Key', () => {
+ const c = freshCfg();
+ c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: 'sk-x' });
+ c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: '' });
+ assert.strictEqual(c.status().hasKey, false);
+});
+
+test('切换 AI 接口类型或服务来源时不会复用旧 API Key', () => {
+ const c = freshCfg();
+ c.save({
+ protocol: 'chat-completions',
+ baseUrl: 'https://api.openai.com/v1',
+ model: 'm',
+ apiKey: 'sk-openai'
+ });
+ c.save({
+ protocol: 'anthropic',
+ baseUrl: 'https://api.anthropic.com/v1',
+ model: 'claude'
+ });
+ assert.strictEqual(c.status().hasKey, false);
+ c.save({
+ protocol: 'anthropic',
+ baseUrl: 'https://api.anthropic.com/v1',
+ model: 'claude',
+ apiKey: 'sk-anthropic'
+ });
+ c.save({
+ protocol: 'anthropic',
+ baseUrl: 'https://proxy.example.com/v1',
+ model: 'claude'
+ });
+ assert.strictEqual(c.status().hasKey, false);
+});
+
+test('非法接口地址被拒绝', () => {
+ const c = freshCfg();
+ assert.throws(() => c.save({ baseUrl: 'ftp://x/v1', model: 'm' }), /http/);
+ assert.throws(() => c.save({ baseUrl: '', model: 'm' }), /不能为空/);
+ assert.throws(() => c.save({ baseUrl: 'https://a/v1', model: '' }), /模型/);
+ assert.throws(() => c.save({ baseUrl: 'https://a/v1#fragment', model: 'm' }), /片段标识/);
+});
+
+test('本地端点识别为无需 Key', () => {
+ const c = freshCfg();
+ c.save({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen' });
+ assert.strictEqual(c.status().isLocal, true);
+ c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt' });
+ assert.strictEqual(c.status().isLocal, false);
+});
+
+test('加密不可用时不落盘 Key', () => {
+ const d = tmp();
+ delete require.cache[cfgPath];
+ const c = require(cfgPath);
+ c.init(d, fakeStorage(false));
+ c.save({ baseUrl: 'https://a.com/v1', model: 'm', apiKey: 'sk-plain' });
+ for (const f of fs.readdirSync(d)) {
+ assert.ok(!fs.readFileSync(path.join(d, f)).toString().includes('sk-plain'), `${f} 落了明文`);
+ }
+ assert.strictEqual(c.get().apiKey, 'sk-plain');
+ assert.strictEqual(c.status().persistent, false);
+});
+
+test('baseUrl 末尾斜杠被规范化', () => {
+ const c = freshCfg();
+ c.save({ baseUrl: 'https://api.openai.com/v1///', model: 'm' });
+ assert.strictEqual(c.status().baseUrl, 'https://api.openai.com/v1');
+});
diff --git a/src/_test/sources.test.js b/src/_test/sources.test.js
new file mode 100644
index 0000000..96099a4
--- /dev/null
+++ b/src/_test/sources.test.js
@@ -0,0 +1,470 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const h = require('./helpers');
+
+h.installFetchStub();
+const sources = require('../sources');
+
+test('注册表:每个源都实现完整接口', () => {
+ const list = sources.listSources();
+ assert.ok(list.length >= 12);
+ for (const s of list) {
+ const m = sources.getSource(s.id);
+ for (const fn of ['list', 'search', 'detail', 'download']) {
+ assert.strictEqual(typeof m[fn], 'function', `${s.id}.${fn} 缺失`);
+ }
+ assert.ok(s.name, `${s.id} 缺 name`);
+ }
+});
+
+test('注册表:未知 id 抛错', () => {
+ assert.throws(() => sources.getSource('nope'), /未知数据源/);
+});
+
+// --- PMC ---
+
+test('pmc: esearch 响应异常时给出可读错误而不是 TypeError', async () => {
+ h.setHandler(h.routes([['esearch.fcgi', { body: { error: 'down' } }]]));
+ await assert.rejects(sources.getSource('pmc').search('x', 1), /无法识别的检索结果/);
+});
+
+test('pmc: postId 不重复拼 PMC 前缀', async () => {
+ const seen = [];
+ h.setHandler(h.routes([
+ ['esummary.fcgi', (u) => { seen.push(u); return h.makeResponse({ body: { result: { 123: { uid: '123', title: 'T', authors: [] } } } }); }]
+ ]));
+ const d = await sources.getSource('pmc').detail('PMC123');
+ assert.strictEqual(d.postId, '123');
+ assert.strictEqual(d.url, 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC123/');
+ assert.ok(!d.url.includes('PMCPMC'), 'URL 里出现了 PMCPMC');
+ assert.ok(seen[0].includes('id=123'), 'esummary 用了带前缀的 id');
+});
+
+test('pmc: 畸形 id 不会把正则搞崩', async () => {
+ h.setHandler(() => h.makeResponse({ body: '' }));
+ await assert.rejects(sources.getSource('pmc').download('12(3'), /无效的 PMC ID/);
+ await assert.rejects(sources.getSource('pmc').download('.*'), /无效的 PMC ID/);
+});
+
+test('pmc: 列表按 uid 归一化 postId', async () => {
+ h.setHandler(h.routes([
+ ['esearch.fcgi', { body: { esearchresult: { count: '40', idlist: ['777'] } } }],
+ ['esummary.fcgi', { body: { result: { 777: { uid: '777', title: 'A', authors: [{ name: 'X' }], pubdate: '2020 Jan' } } } }]
+ ]));
+ const r = await sources.getSource('pmc').search('kw', 1);
+ assert.strictEqual(r.items[0].postId, '777');
+ assert.strictEqual(r.maxPage, 2);
+});
+
+// --- DOAJ ---
+
+test('doaj: postId 不被二次编码', async () => {
+ const urls = [];
+ h.setHandler(h.routes([
+ ['search/articles', { body: { total: 1, results: [{ id: '10.1234/abc', bibjson: { title: 'T', author: [], link: [] } }] } }],
+ ['api/v2/articles/', (u) => { urls.push(u); return h.makeResponse({ body: { bibjson: { title: 'T', author: [], link: [] } } }); }]
+ ]));
+ const doaj = sources.getSource('doaj');
+ const r = await doaj.search('kw', 1);
+ assert.strictEqual(r.items[0].postId, '10.1234/abc', 'postId 不该预先编码');
+ await doaj.detail(r.items[0].postId);
+ assert.ok(urls[0].includes('10.1234%2Fabc'), '详情 URL 编码错误: ' + urls[0]);
+ assert.ok(!urls[0].includes('%252F'), '出现二次编码: ' + urls[0]);
+});
+
+test('doaj: DOAJ 页链接正确编码', async () => {
+ h.setHandler(h.routes([['api/v2/articles/', { body: { bibjson: { link: [] } } }]]));
+ const d = await sources.getSource('doaj').download('10.1234/abc');
+ const page = d.links.find((l) => l.name === 'DOAJ 页');
+ assert.strictEqual(page.url, 'https://doaj.org/article/10.1234%2Fabc');
+});
+
+// --- Sci-Hub ---
+
+test('scihub: 跳过广告 iframe 找到真正的 PDF', async () => {
+ h.setHandler(() => h.makeResponse({
+ body: ''
+ }));
+ const d = await sources.getSource('scihub').download('10.1038/nature12373');
+ assert.strictEqual(d.files[0].link, 'https://sci-hub.se/downloads/2020/x.pdf');
+});
+
+test('scihub: DOI 不存在时只请求一个镜像', async () => {
+ const hits = [];
+ h.setHandler((u) => { hits.push(u); return h.makeResponse({ body: 'article not found' }); });
+ await assert.rejects(sources.getSource('scihub').detail('10.1/x'), /不存在/);
+ assert.strictEqual(hits.length, 1, `不该轮询全部镜像,实际请求 ${hits.length} 次`);
+});
+
+test('scihub: 非 DOI 关键词返回空而不抛错', async () => {
+ const r = await sources.getSource('scihub').search('随便搜点什么', 1);
+ assert.deepStrictEqual(r.items, []);
+ assert.ok(r.note);
+});
+
+// --- LibGen ---
+
+test('libgen: maxPage 只看分页控件,忽略页脚干扰链接', async () => {
+ const card = '';
+ const footer = '';
+ const pager = '';
+ h.setHandler(() => h.makeResponse({ body: card + footer + pager }));
+ const r = await sources.getSource('libgen').search('godel escher', 1);
+ assert.strictEqual(r.items.length, 1);
+ assert.strictEqual(r.maxPage, 3, '页脚的 page=999 被误算进来了');
+});
+
+test('libgen: 无分页控件时不虚报页数', async () => {
+ const card = '';
+ h.setHandler(() => h.makeResponse({ body: card + 'junk ' }));
+ const r = await sources.getSource('libgen').search('solo book', 1);
+ assert.strictEqual(r.maxPage, 1);
+});
+
+test('libgen: JSON-LD image 为对象时详情不崩溃', async () => {
+ const ld = JSON.stringify({ '@type': 'Book', name: 'B', image: { '@type': 'ImageObject', url: '/c.jpg' } });
+ h.setHandler(() => h.makeResponse({
+ body: `B `
+ }));
+ const d = await sources.getSource('libgen').detail('web:5');
+ assert.strictEqual(d.title, 'B');
+ assert.ok(/\/c\.jpg$/.test(d.cover), 'cover 解析失败: ' + d.cover);
+});
+
+test('libgen: 关键词过短直接返回提示', async () => {
+ const r = await sources.getSource('libgen').search('ab', 1);
+ assert.deepStrictEqual(r.items, []);
+ assert.ok(r.note);
+});
+
+// --- Standard Ebooks ---
+
+test('standardebooks: author 为字符串时不丢作者', async () => {
+ h.setHandler(h.routes([['feeds/opds/all', {
+ body: {
+ publications: [{
+ metadata: { identifier: 'https://standardebooks.org/ebooks/jane-austen/emma', title: 'Emma', author: 'Jane Austen' },
+ images: []
+ }]
+ }
+ }]]));
+ const r = await sources.getSource('standardebooks').search('emma', 1);
+ assert.strictEqual(r.items[0].subtitle, 'Jane Austen');
+});
+
+test('standardebooks: author 混排对象与字符串', async () => {
+ h.setHandler(h.routes([['feeds/opds/all', {
+ body: {
+ publications: [{
+ metadata: { identifier: 'https://standardebooks.org/ebooks/a/b', title: 'T', author: [{ name: 'A' }, 'B'] },
+ images: []
+ }]
+ }
+ }]]));
+ const r = await sources.getSource('standardebooks').search('t', 1);
+ assert.strictEqual(r.items[0].subtitle, 'A, B');
+});
+
+test('standardebooks: 非法 slug 被拒绝', async () => {
+ await assert.rejects(sources.getSource('standardebooks').detail('../../etc/passwd'), /无效的/);
+});
+
+// --- Open Library ---
+
+test('openlibrary: 详情解析作者姓名', async () => {
+ h.setHandler(h.routes([
+ [/works\/OL1W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/OL1A' } }], subjects: [] } }],
+ [/authors\/OL1A\.json/, { body: { name: 'Ursula Le Guin' } }]
+ ]));
+ const d = await sources.getSource('openlibrary').detail('OL1W');
+ assert.deepStrictEqual(d.authors, ['Ursula Le Guin']);
+});
+
+test('openlibrary: 单个作者取不到不影响整体', async () => {
+ h.setHandler(h.routes([
+ [/works\/OL2W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/BAD' } }, { author: { key: '/authors/OK' } }], subjects: [] } }],
+ // 404 不触发重试,避免这条用例白等两轮退避
+ [/authors\/BAD\.json/, { status: 404 }],
+ [/authors\/OK\.json/, { body: { name: 'Good' } }]
+ ]));
+ const d = await sources.getSource('openlibrary').detail('OL2W');
+ assert.deepStrictEqual(d.authors, ['Good']);
+});
+
+test('openlibrary: 下载只给 archive.org 真实存在的文件', async () => {
+ h.setHandler(h.routes([
+ ['editions.json', { body: { entries: [{ ocaid: 'someitem' }] } }],
+ ['archive.org/metadata/', {
+ body: { files: [{ name: 'someitem.pdf', format: 'Text PDF' }, { name: 'thumb.jpg', format: 'JPEG' }] }
+ }]
+ ]));
+ const d = await sources.getSource('openlibrary').download('OL3W');
+ assert.strictEqual(d.files.length, 1, '推了不存在的格式: ' + JSON.stringify(d.files));
+ assert.strictEqual(d.files[0].format, 'PDF');
+ assert.ok(d.files[0].link.includes('someitem.pdf'));
+});
+
+test('openlibrary: 借阅制条目被跳过', async () => {
+ h.setHandler(h.routes([
+ ['editions.json', { body: { entries: [{ ocaid: 'lend', access_restricted: 'borrow' }] } }]
+ ]));
+ const d = await sources.getSource('openlibrary').download('OL4W');
+ assert.deepStrictEqual(d.files, []);
+});
+
+// --- bioRxiv ---
+
+test('biorxiv: 瞬时故障会重试而不是直接失败', async () => {
+ let n = 0;
+ h.setHandler(() => {
+ n++;
+ // 502 与超时走的是同一条 isRetryable 分支,用 502 避免真的等满超时
+ if (n <= 2) return h.makeResponse({ status: 502 });
+ return h.makeResponse({ body: { messages: [{ total: 100 }], collection: [] } });
+ });
+ const r = await sources.getSource('biorxiv').list(1);
+ assert.ok(n >= 3, `没有重试,只请求了 ${n} 次`);
+ assert.ok(r.maxPage >= 1);
+});
+
+test('biorxiv: 超时被判定为可重试(回归 504|502|503 正则漏判)', () => {
+ const { isRetryable } = require('../sources/http');
+ assert.strictEqual(isRetryable(new Error('请求超时,站点无响应')), true);
+ assert.strictEqual(isRetryable(new Error('网络连接失败,请检查网络或代理设置')), true);
+ const src = require('fs').readFileSync(require.resolve('../sources/biorxiv.js'), 'utf8');
+ assert.ok(!/504\|502\|503/.test(src), '旧的字符串匹配门仍在');
+});
+
+test('biorxiv: 不支持搜索时明确报错', async () => {
+ await assert.rejects(sources.getSource('biorxiv').search('x', 1), /不支持搜索/);
+});
+
+// --- MOTW ---
+
+test('motw: 分页用 offset/limit 且随页码递增', async () => {
+ const urls = [];
+ h.setHandler((u) => {
+ urls.push(u);
+ return h.makeResponse({ body: { _items: [], _meta: { total: 1000, max_results: 48 } } });
+ });
+ const motw = sources.getSource('motw');
+ await motw.list(1);
+ await motw.list(3);
+ assert.ok(urls[0].includes('offset=0&limit=48'), urls[0]);
+ assert.ok(urls[1].includes('offset=96&limit=48'), urls[1]);
+});
+
+test('motw: 未缓存的详情给出可操作提示', async () => {
+ await assert.rejects(sources.getSource('motw').detail('unknown-id'), /重新进入/);
+});
+
+// --- arXiv ---
+
+test('arxiv: 解析 atom feed 并取 pdf 链接', async () => {
+ const xml = `40
+ http://arxiv.org/abs/2201.00978v1 Paper T
+ S 2022-01-03T00:00:00Z
+ A One
+
+ `;
+ h.setHandler(() => h.makeResponse({ body: xml }));
+ const r = await sources.getSource('arxiv').search('transformer', 1);
+ assert.strictEqual(r.items[0].postId, '2201.00978v1');
+ assert.strictEqual(r.maxPage, 2);
+ const d = await sources.getSource('arxiv').download('2201.00978v1');
+ assert.strictEqual(d.files[0].link, 'https://arxiv.org/pdf/2201.00978v1');
+});
+
+// --- Z-Library ---
+
+test('zlib: postId 缺 hash 时详情仍可用', async () => {
+ const zlib = h.freshRequire('sources/zlib.js');
+ const auth = require('../sources/zlib-auth');
+ const origSession = auth.getSession;
+ const origRead = auth.read;
+ auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' });
+ auth.read = () => ({ email: 'e', password: 'p', userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' });
+ try {
+ const urls = [];
+ h.setHandler((u) => {
+ urls.push(u);
+ return h.makeResponse({ body: { success: 1, book: { title: 'B', author: 'X' } } });
+ });
+ const d = await zlib.detail('123/');
+ assert.strictEqual(d.title, 'B');
+ assert.ok(urls[0].includes('/eapi/book/123'), urls[0]);
+ assert.ok(!urls[0].includes('/eapi/book/123/?'), '缺 hash 时不该留下尾斜杠: ' + urls[0]);
+ } finally {
+ auth.getSession = origSession;
+ auth.read = origRead;
+ }
+});
+
+test('zlib: 完全无效的 id 仍然拒绝', async () => {
+ const zlib = h.freshRequire('sources/zlib.js');
+ await assert.rejects(zlib.detail('not-an-id'), /无效的 Z-Library ID/);
+});
+
+// 实测:会话过期时 /file 返回 400 + {"success":0,"error":"Please login"}。
+// 若按 HTTP 状态码短路,真实原因会被吞掉,自动重登也不会触发。
+test('zlib: 4xx+JSON 的会话过期能被识别并自动重新登录', async () => {
+ const zlib = h.freshRequire('sources/zlib.js');
+ const auth = require('../sources/zlib-auth');
+ const orig = { read: auth.read, getSession: auth.getSession, setSession: auth.setSession, clearSession: auth.clearSession };
+ let session = { userId: 'old', userKey: 'stale', mirror: 'https://z-lib.fm' };
+ auth.read = () => ({ email: 'e@x.com', password: 'p', ...session });
+ auth.getSession = () => (session.userKey ? session : null);
+ auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
+ auth.clearSession = () => { session = { userId: '', userKey: '', mirror: '' }; };
+ try {
+ let loggedIn = false;
+ h.setHandler((url) => {
+ if (url.includes('/rpc.php')) {
+ loggedIn = true;
+ return h.makeResponse({
+ headers: {
+ 'set-cookie': [
+ 'remix_userid=42; Path=/; Secure; HttpOnly',
+ 'remix_userkey=fresh; Path=/; Secure; HttpOnly'
+ ]
+ },
+ body: { errors: [], response: { redirect: '/' } }
+ });
+ }
+ if (url.includes('userKey=fresh')) {
+ return h.makeResponse({ body: { success: 1, file: { downloadLink: 'https://cdn/x.pdf', extension: 'pdf' } } });
+ }
+ return h.makeResponse({ status: 400, body: { success: 0, error: 'Please login' } });
+ });
+ const d = await zlib.download('123/abc');
+ assert.ok(loggedIn, '过期会话没有触发重新登录');
+ assert.strictEqual(d.files[0].link, 'https://cdn/x.pdf');
+ } finally {
+ Object.assign(auth, orig);
+ }
+});
+
+test('zlib: 凭据错误时报出服务端原因而不是 HTTP 状态码', async () => {
+ const zlib = h.freshRequire('sources/zlib.js');
+ const auth = require('../sources/zlib-auth');
+ const orig = { read: auth.read, getSession: auth.getSession, write: auth.write, clear: auth.clear };
+ // login() 先写盘,doLogin() 再读回来,所以 stub 要如实模拟这个往返
+ let stored = null;
+ auth.read = () => stored;
+ auth.getSession = () => null;
+ auth.write = (c) => { stored = { ...c }; };
+ auth.clear = () => { stored = null; };
+ try {
+ let request = null;
+ h.setHandler((url, options) => {
+ request = { url, body: options.body };
+ return h.makeResponse({
+ body: {
+ errors: [],
+ response: {
+ validationError: true,
+ fields: ['email', 'password'],
+ message: 'Incorrect email or password'
+ }
+ }
+ });
+ });
+ const r = await zlib.login('e@x.com', 'wrong');
+ assert.strictEqual(r.ok, false);
+ assert.match(r.error, /Incorrect email or password/, '真实原因被 HTTP 状态码盖掉了');
+ assert.ok(request.url.endsWith('/rpc.php'));
+ assert.match(request.body, /action=login/);
+ assert.match(request.body, /gg_json_mode=1/);
+ } finally {
+ Object.assign(auth, orig);
+ }
+});
+
+test('zlib: RPC 登录从安全 Cookie 建立会话', async () => {
+ const zlib = h.freshRequire('sources/zlib.js');
+ const auth = require('../sources/zlib-auth');
+ const orig = {
+ read: auth.read,
+ getSession: auth.getSession,
+ write: auth.write,
+ setSession: auth.setSession,
+ clear: auth.clear
+ };
+ let stored = null;
+ let session = null;
+ auth.read = () => stored;
+ auth.getSession = () => session;
+ auth.write = (c) => { stored = { ...c }; };
+ auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
+ auth.clear = () => { stored = null; session = null; };
+ try {
+ h.setHandler(() => h.makeResponse({
+ headers: {
+ 'set-cookie': [
+ 'remix_userid=42; Path=/; Secure; HttpOnly',
+ 'remix_userkey=key%2Bvalue; Path=/; Secure; HttpOnly'
+ ]
+ },
+ body: { errors: [], response: { redirect: '/' } }
+ }));
+ const r = await zlib.login('e@x.com', 'correct');
+ assert.strictEqual(r.ok, true);
+ assert.strictEqual(session.userId, '42');
+ assert.strictEqual(session.userKey, 'key+value');
+ assert.match(session.mirror, /^https:\/\//);
+ } finally {
+ Object.assign(auth, orig);
+ }
+});
+
+test('zlib: 可注入同源浏览器登录传输并持久化会话', async () => {
+ const zlib = h.freshRequire('sources/zlib.js');
+ const auth = require('../sources/zlib-auth');
+ const orig = {
+ read: auth.read,
+ getSession: auth.getSession,
+ write: auth.write,
+ setSession: auth.setSession,
+ clear: auth.clear
+ };
+ let stored = null;
+ let session = null;
+ auth.read = () => stored;
+ auth.getSession = () => session;
+ auth.write = (c) => { stored = { ...c }; };
+ auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
+ auth.clear = () => { stored = null; session = null; };
+ zlib.setLoginTransport(async (mirror, email, password) => {
+ assert.match(mirror, /^https:\/\//);
+ assert.strictEqual(email, 'e@x.com');
+ assert.strictEqual(password, 'correct');
+ return { userId: 'browser-user', userKey: 'browser-key' };
+ });
+ try {
+ const result = await zlib.login('e@x.com', 'correct');
+ assert.strictEqual(result.ok, true);
+ assert.strictEqual(session.userId, 'browser-user');
+ assert.strictEqual(session.userKey, 'browser-key');
+ } finally {
+ zlib.setLoginTransport(null);
+ Object.assign(auth, orig);
+ }
+});
+
+// --- Gutenberg ---
+
+test('gutenberg: 解析格式与封面', async () => {
+ h.setHandler(h.routes([['gutendex.com/books', {
+ body: {
+ count: 64,
+ results: [{
+ id: 11, title: 'Alice', authors: [{ name: 'Carroll' }],
+ formats: { 'application/epub+zip': 'https://x/a.epub', 'image/jpeg': 'https://x/c.jpg' }
+ }]
+ }
+ }]]));
+ const r = await sources.getSource('gutenberg').search('alice', 1);
+ assert.strictEqual(r.items[0].postId, '11');
+ assert.strictEqual(r.items[0].cover, 'https://x/c.jpg');
+ assert.strictEqual(r.maxPage, 2);
+});
diff --git a/src/_test/store.test.js b/src/_test/store.test.js
new file mode 100644
index 0000000..316f949
--- /dev/null
+++ b/src/_test/store.test.js
@@ -0,0 +1,707 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const h = require('./helpers');
+
+h.installFetchStub();
+h.setHandler(() => h.makeResponse({ status: 404 }));
+
+const store = require('../library/store');
+
+function tmpDir(tag) {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-${tag}-`));
+ return d;
+}
+const created = [];
+function freshRoot(tag) {
+ const d = tmpDir(tag);
+ created.push(d);
+ store.init(d);
+ return d;
+}
+test.after(() => {
+ for (const d of created) {
+ try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
+ }
+});
+
+test('init 建出目录结构', () => {
+ const root = freshRoot('init');
+ assert.ok(fs.existsSync(path.join(root, 'files')));
+ assert.ok(fs.existsSync(path.join(root, 'covers')));
+ assert.strictEqual(store.getRoot(), path.resolve(root));
+});
+
+test('add / get / list 往返', () => {
+ freshRoot('crud');
+ const it = store.add({ title: '测试书', authors: ['作者'], sourceId: 's', sourcePostId: 1 });
+ assert.ok(it.id);
+ const got = store.get(it.id);
+ assert.strictEqual(got.title, '测试书');
+ assert.strictEqual(got.sourcePostId, '1', 'sourcePostId 应统一为字符串');
+ assert.strictEqual(store.list().length, 1);
+ assert.ok(store.findBySource('s', 1), '数字 postId 应能匹配');
+ assert.ok(store.findBySource('s', '1'));
+});
+
+test('批量导入本地文件可按上一级目录创建并复用书架', () => {
+ const root = freshRoot('local-import-shelves');
+ const source = path.join(root, 'source');
+ for (const folder of ['文学', '技术']) fs.mkdirSync(path.join(source, folder), { recursive: true });
+ const files = [
+ path.join(source, '文学', '小说.epub'),
+ path.join(source, '文学', '诗集.pdf'),
+ path.join(source, '技术', '手册.txt')
+ ];
+ files.forEach((file, index) => fs.writeFileSync(file, `fixture-${index}`));
+ const existingLiterature = store.addShelf('文学');
+ const records = files.map((file) => ({
+ path: file,
+ name: path.basename(file),
+ format: path.extname(file).slice(1).toUpperCase(),
+ parentName: path.basename(path.dirname(file))
+ }));
+ const imported = store.importLocal(records, 'shelf');
+ assert.strictEqual(imported.added, 3);
+ assert.strictEqual(imported.skipped, 0);
+ assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name).sort(), ['技术', '文学']);
+ const literature = store.listShelves().find((shelf) => shelf.name === '文学');
+ assert.strictEqual(literature.id, existingLiterature.id);
+ assert.strictEqual(
+ store.list().filter((item) => item.shelfId === literature.id).length,
+ 2
+ );
+ const repeated = store.importLocal(records, 'shelf');
+ assert.deepStrictEqual(
+ {
+ added: repeated.added,
+ skipped: repeated.skipped,
+ skippedDuplicates: repeated.skippedDuplicates
+ },
+ { added: 0, skipped: 3, skippedDuplicates: 3 }
+ );
+ assert.strictEqual(store.list().length, 3);
+});
+
+test('本地导入按规范路径跳过书库中已有的同一文件', () => {
+ const root = freshRoot('local-import-same-path');
+ const source = path.join(root, 'source', 'same.pdf');
+ fs.mkdirSync(path.dirname(source), { recursive: true });
+ fs.writeFileSync(source, 'same-path-content');
+ const record = { path: source, name: 'same.pdf', parentName: 'source' };
+
+ assert.strictEqual(store.importLocal([record]).added, 1);
+ const repeated = store.importLocal([record]);
+
+ assert.deepStrictEqual(
+ {
+ added: repeated.added,
+ skipped: repeated.skipped,
+ skippedDuplicates: repeated.skippedDuplicates
+ },
+ { added: 0, skipped: 1, skippedDuplicates: 1 }
+ );
+ assert.strictEqual(store.list().length, 1);
+});
+
+test('本地导入按文件字节跳过不同路径下的副本', () => {
+ const root = freshRoot('local-import-copy');
+ const original = path.join(root, 'original', 'first.pdf');
+ const copy = path.join(root, 'copy', 'renamed.pdf');
+ fs.mkdirSync(path.dirname(original), { recursive: true });
+ fs.mkdirSync(path.dirname(copy), { recursive: true });
+ fs.writeFileSync(original, 'identical-file-bytes');
+ fs.copyFileSync(original, copy);
+
+ assert.strictEqual(store.importLocal([{ path: original }]).added, 1);
+ const copied = store.importLocal([{
+ path: copy,
+ name: 'Completely Different Title.pdf',
+ title: 'Completely Different Title'
+ }]);
+
+ assert.deepStrictEqual(
+ {
+ added: copied.added,
+ skipped: copied.skipped,
+ skippedDuplicates: copied.skippedDuplicates
+ },
+ { added: 0, skipped: 1, skippedDuplicates: 1 }
+ );
+ assert.strictEqual(store.list().length, 1);
+});
+
+test('本地导入保留同名但字节不同的版本', () => {
+ const root = freshRoot('local-import-editions');
+ const first = path.join(root, 'edition-one', 'Shared Title.pdf');
+ const second = path.join(root, 'edition-two', 'Shared Title.pdf');
+ fs.mkdirSync(path.dirname(first), { recursive: true });
+ fs.mkdirSync(path.dirname(second), { recursive: true });
+ fs.writeFileSync(first, 'edition-A');
+ fs.writeFileSync(second, 'edition-B');
+
+ const imported = store.importLocal([
+ { path: first, title: 'Shared Title' },
+ { path: second, title: 'Shared Title' }
+ ]);
+
+ assert.deepStrictEqual(
+ {
+ added: imported.added,
+ skipped: imported.skipped,
+ skippedDuplicates: imported.skippedDuplicates
+ },
+ { added: 2, skipped: 0, skippedDuplicates: 0 }
+ );
+ assert.deepStrictEqual(store.list().map((item) => item.title), ['Shared Title', 'Shared Title']);
+});
+
+test('混合批量导入同时跳过已有路径、已有副本和批内副本', () => {
+ const root = freshRoot('local-import-mixed');
+ const existing = path.join(root, 'existing', 'book.epub');
+ const existingCopy = path.join(root, 'incoming', 'existing-copy.epub');
+ const fresh = path.join(root, 'incoming', 'fresh.epub');
+ const freshCopy = path.join(root, 'incoming-copy', 'fresh-copy.epub');
+ for (const file of [existing, existingCopy, fresh, freshCopy]) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ }
+ fs.writeFileSync(existing, 'existing-content');
+ fs.copyFileSync(existing, existingCopy);
+ fs.writeFileSync(fresh, 'brand-new-content');
+ fs.copyFileSync(fresh, freshCopy);
+ store.importLocal([{ path: existing }]);
+
+ const imported = store.importLocal([
+ { path: existing },
+ { path: existingCopy },
+ { path: fresh },
+ { path: freshCopy }
+ ]);
+
+ assert.deepStrictEqual(
+ {
+ added: imported.added,
+ skipped: imported.skipped,
+ skippedDuplicates: imported.skippedDuplicates
+ },
+ { added: 1, skipped: 3, skippedDuplicates: 3 }
+ );
+ assert.strictEqual(imported.items[0].files[0].path, fs.realpathSync(fresh));
+ assert.strictEqual(store.list().length, 2);
+});
+
+test('本地批量导入写入失败时回滚条目、分类和去重状态', () => {
+ const root = freshRoot('local-import-rollback');
+ const source = path.join(root, '回滚分类');
+ const first = path.join(source, 'first.pdf');
+ const duplicate = path.join(source, 'first-copy.pdf');
+ const second = path.join(source, 'second.pdf');
+ fs.mkdirSync(source, { recursive: true });
+ fs.writeFileSync(first, 'duplicate-content');
+ fs.copyFileSync(first, duplicate);
+ fs.writeFileSync(second, 'distinct-content');
+ const records = [first, duplicate, second].map((file) => ({
+ path: file,
+ parentName: '回滚分类'
+ }));
+
+ const file = path.join(root, 'library.json');
+ const originalRename = fs.renameSync;
+ let failed = false;
+ fs.renameSync = function renameWithFailure(sourcePath, destination) {
+ if (!failed && sourcePath === `${file}.tmp` && destination === file) {
+ failed = true;
+ throw new Error('simulated replace failure');
+ }
+ return originalRename.apply(this, arguments);
+ };
+ try {
+ assert.throws(
+ () => store.importLocal(records, 'shelf'),
+ /书库索引写入失败/
+ );
+ } finally {
+ fs.renameSync = originalRename;
+ }
+
+ assert.ok(failed);
+ assert.deepStrictEqual(store.list(), []);
+ assert.deepStrictEqual(store.listShelves(), []);
+ assert.ok(!fs.existsSync(file));
+ assert.ok(!fs.existsSync(`${file}.tmp`));
+
+ const retried = store.importLocal(records, 'shelf');
+ assert.deepStrictEqual(
+ {
+ added: retried.added,
+ skipped: retried.skipped,
+ skippedDuplicates: retried.skippedDuplicates
+ },
+ { added: 2, skipped: 1, skippedDuplicates: 1 }
+ );
+ assert.strictEqual(store.list().length, 2);
+ assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name), ['回滚分类']);
+});
+
+test('批量导入本地文件可按上一级目录创建标签或保持不分类', () => {
+ const root = freshRoot('local-import-tags');
+ const folder = path.join(root, '旧分类');
+ fs.mkdirSync(folder, { recursive: true });
+ const taggedFile = path.join(folder, '标签书.pdf');
+ const plainFile = path.join(folder, '普通书.epub');
+ fs.writeFileSync(taggedFile, 'tagged');
+ fs.writeFileSync(plainFile, 'plain');
+ const existingTag = store.addTag('旧分类');
+ const tagged = store.importLocal([{
+ path: taggedFile,
+ name: '标签书.pdf',
+ parentName: '旧分类'
+ }], 'tag');
+ assert.strictEqual(tagged.added, 1);
+ assert.deepStrictEqual(store.get(tagged.items[0].id).tags, ['旧分类']);
+ const taggedCatalog = store.listTags().find((tag) => tag.name === '旧分类');
+ assert.strictEqual(taggedCatalog.id, existingTag.id);
+ assert.strictEqual(taggedCatalog.count, 1);
+
+ const plain = store.importLocal([{
+ path: plainFile,
+ name: '普通书.epub',
+ parentName: '旧分类'
+ }], 'none');
+ assert.strictEqual(plain.added, 1);
+ assert.deepStrictEqual(store.get(plain.items[0].id).tags, []);
+ assert.strictEqual(store.get(plain.items[0].id).shelfId, null);
+ assert.throws(() => store.importLocal([], 'invalid'), /分类方式无效/);
+});
+
+test('书库内文件存相对路径,外部文件存绝对路径', () => {
+ const root = freshRoot('paths');
+ const inside = path.join(root, 'files', 'a.pdf');
+ fs.writeFileSync(inside, 'x');
+ const outsideDir = tmpDir('outside');
+ created.push(outsideDir);
+ const outside = path.join(outsideDir, 'b.pdf');
+ fs.writeFileSync(outside, 'y');
+
+ const it = store.add({ title: 'T', files: [{ path: inside }, { path: outside }] });
+ const raw = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
+ const stored = raw.items[0].files.map((f) => f.path);
+ assert.ok(stored.includes('files/a.pdf'), '库内文件未转相对路径: ' + stored);
+ assert.ok(stored.some((p) => path.isAbsolute(p)), '库外文件不应转相对路径');
+
+ // 对外一律给绝对路径
+ for (const f of it.files) assert.ok(path.isAbsolute(f.path), f.path);
+ assert.ok(it.files.every((f) => f.exists));
+});
+
+test('expand 如实反映磁盘状态', () => {
+ const root = freshRoot('missing');
+ const p = path.join(root, 'files', 'gone.pdf');
+ fs.writeFileSync(p, 'x');
+ const it = store.add({ title: 'T', files: [{ path: p }] });
+ assert.strictEqual(store.get(it.id).missing, false);
+ fs.unlinkSync(p);
+ const after = store.get(it.id);
+ assert.strictEqual(after.files[0].exists, false);
+ assert.strictEqual(after.missing, true);
+});
+
+test('allocFilePath 避免覆盖同名文件', () => {
+ const root = freshRoot('alloc');
+ const first = store.allocFilePath('book.pdf');
+ fs.writeFileSync(first, 'a');
+ const second = store.allocFilePath('book.pdf');
+ assert.notStrictEqual(first, second);
+ assert.ok(second.includes('(1)'), second);
+});
+
+test('sanitize 去掉非法字符', () => {
+ assert.strictEqual(store.sanitize('a/b:c*d?.pdf'), 'a_b_c_d_.pdf');
+ assert.strictEqual(store.sanitize(''), 'download');
+ assert.strictEqual(store.sanitize(' '), 'download');
+});
+
+test('remove 默认保留文件,deleteFiles 才删', () => {
+ const root = freshRoot('remove');
+ const p = path.join(root, 'files', 'keep.pdf');
+ fs.writeFileSync(p, 'x');
+ const a = store.add({ title: 'A', files: [{ path: p }] });
+ store.remove(a.id, false);
+ assert.ok(fs.existsSync(p), '未勾选删除时不该删文件');
+
+ const b = store.add({ title: 'B', files: [{ path: p }] });
+ store.remove(b.id, true);
+ assert.ok(!fs.existsSync(p), '勾选删除后文件应被删除');
+});
+
+test('remove 不删书库目录外的用户文件', () => {
+ freshRoot('remove-outside');
+ const outDir = tmpDir('user');
+ created.push(outDir);
+ const p = path.join(outDir, 'mine.pdf');
+ fs.writeFileSync(p, 'x');
+ const it = store.add({ title: 'T', files: [{ path: p }] });
+ store.remove(it.id, true);
+ assert.ok(fs.existsSync(p), '原地引用的外部文件被误删了');
+});
+
+test('scan 导入孤立文件并跳过非书籍扩展名', () => {
+ const root = freshRoot('scan');
+ fs.writeFileSync(path.join(root, 'files', 'novel.epub'), 'x');
+ fs.writeFileSync(path.join(root, 'files', 'notes.exe'), 'x');
+ const r = store.scan();
+ assert.strictEqual(r.added, 1, '应只导入 epub');
+ assert.strictEqual(store.list()[0].title, 'novel');
+ const again = store.scan();
+ assert.strictEqual(again.added, 0, '重复扫描不应重复导入');
+});
+
+test('attachFile 幂等,不产生重复条目文件', () => {
+ const root = freshRoot('attach');
+ const it = store.add({ title: 'T' });
+ const p = path.join(root, 'files', 'x.pdf');
+ fs.writeFileSync(p, 'x');
+ store.attachFile(it.id, p);
+ const after = store.attachFile(it.id, p);
+ assert.strictEqual(after.files.length, 1, '重复挂载产生了重复记录');
+});
+
+test('生成封面写入 covers 并随条目删除', () => {
+ const root = freshRoot('generated-cover');
+ const it = store.add({ title: 'T' });
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
+ const cover = store.setGeneratedCover(it.id, jpeg);
+ assert.ok(cover.startsWith(path.join(root, 'covers')), cover);
+ assert.ok(fs.existsSync(cover));
+ assert.strictEqual(store.get(it.id).cover, cover);
+ store.remove(it.id, false);
+ assert.ok(!fs.existsSync(cover), '移除条目后遗留了生成封面');
+});
+
+test('生成封面不覆盖更新后的来源封面', () => {
+ const root = freshRoot('generated-priority');
+ const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
+ store.update(it.id, { cover: 'https://new.example/cover.jpg' });
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
+ assert.strictEqual(store.setGeneratedCover(it.id, jpeg, it.cover), '');
+ assert.strictEqual(store.get(it.id).cover, 'https://new.example/cover.jpg');
+
+ const blank = store.add({ title: 'Blank' });
+ const manual = path.join(root, 'manual.jpg');
+ fs.writeFileSync(manual, jpeg);
+ store.update(blank.id, { cover: manual });
+ assert.strictEqual(store.setGeneratedCover(blank.id, jpeg, ''), '');
+ assert.strictEqual(store.get(blank.id).cover, manual);
+});
+
+test('生成封面拒绝非 JPEG 和过大数据', () => {
+ freshRoot('generated-validation');
+ const it = store.add({ title: 'T' });
+ assert.throws(() => store.setGeneratedCover(it.id, Buffer.from('not an image')), /JPEG/);
+ const large = Buffer.alloc(2 * 1024 * 1024 + 1);
+ large[0] = 0xff; large[1] = 0xd8; large[2] = 0xff;
+ assert.throws(() => store.setGeneratedCover(it.id, large), /JPEG/);
+});
+
+test('远程封面下载完成后不覆盖期间更新的封面', async () => {
+ const root = freshRoot('remote-cover-race');
+ let release;
+ let startedResolve;
+ const started = new Promise((resolve) => { startedResolve = resolve; });
+ h.setHandler(() => {
+ startedResolve();
+ return new Promise((resolve) => {
+ release = () => resolve({
+ ok: true,
+ arrayBuffer: async () => Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]).buffer
+ });
+ });
+ });
+ const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
+ const job = store.ensureCoverCached(it.id);
+ await started;
+ const manual = path.join(root, 'manual.jpg');
+ fs.writeFileSync(manual, Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ store.update(it.id, { cover: manual });
+ release();
+ assert.strictEqual(await job, '');
+ assert.strictEqual(store.get(it.id).cover, manual);
+ h.setHandler(() => h.makeResponse({ status: 404 }));
+});
+
+test('远程封面缓存拒绝网页响应', async () => {
+ const root = freshRoot('remote-cover-html');
+ h.setHandler(() => ({
+ ok: true,
+ arrayBuffer: async () => Uint8Array.from(Buffer.from('not an image')).buffer
+ }));
+ const it = store.add({ title: 'T', cover: 'https://example.com/cover.jpg' });
+ assert.strictEqual(await store.ensureCoverCached(it.id), '');
+ assert.strictEqual(store.get(it.id).cover, 'https://example.com/cover.jpg');
+ assert.deepStrictEqual(fs.readdirSync(path.join(root, 'covers')), []);
+ h.setHandler(() => h.makeResponse({ status: 404 }));
+});
+
+test('索引损坏时报错而不是静默清空书库', () => {
+ const root = freshRoot('corrupt');
+ store.add({ title: '重要的书' });
+ fs.writeFileSync(path.join(root, 'library.json'), '{ 坏掉的 json');
+ store.init(root);
+ assert.throws(() => store.list(), /书库索引读取失败/);
+});
+
+test('写入后可从 .bak 恢复', () => {
+ const root = freshRoot('bak');
+ store.add({ title: '书' });
+ const idx = path.join(root, 'library.json');
+ fs.copyFileSync(idx, idx + '.bak');
+ fs.unlinkSync(idx);
+ store.init(root);
+ assert.strictEqual(store.list().length, 1, '未从 .bak 恢复');
+});
+
+test('migrateTo 搬运文件并保持条目可用', () => {
+ const src = freshRoot('mig-src');
+ const shelf = store.addShelf({ name: '迁移书架' });
+ const p = path.join(src, 'files', 'm.pdf');
+ fs.writeFileSync(p, 'data');
+ const added = store.add({ title: 'M', shelfId: shelf.id, tags: ['迁移'], files: [{ path: p }] });
+ const oldCover = store.setGeneratedCover(
+ added.id,
+ Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9])
+ );
+
+ const dest = tmpDir('mig-dest');
+ created.push(dest);
+ store.migrateTo(dest);
+ store.finalizeMigration();
+
+ assert.strictEqual(store.getRoot(), path.resolve(dest));
+ const items = store.list();
+ assert.strictEqual(items.length, 1);
+ assert.ok(items[0].files[0].exists, '迁移后文件丢失');
+ assert.ok(items[0].files[0].path.startsWith(path.resolve(dest)), items[0].files[0].path);
+ assert.ok(items[0].cover.startsWith(path.resolve(dest)), items[0].cover);
+ assert.ok(fs.existsSync(items[0].cover), '迁移后生成封面丢失');
+ assert.strictEqual(items[0].shelfId, shelf.id);
+ assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['迁移书架']);
+ assert.ok(!fs.existsSync(p), '旧文件未清理');
+ assert.ok(!fs.existsSync(oldCover), '旧生成封面未清理');
+});
+
+test('migrateTo 拒绝互相包含的目录', () => {
+ const src = freshRoot('mig-nest');
+ assert.throws(() => store.migrateTo(path.join(src, 'sub')), /不能互相包含/);
+});
+
+test('migrateTo 拒绝已有书库的目标目录', () => {
+ freshRoot('mig-occupied');
+ store.add({ title: 'A' });
+ const dest = tmpDir('mig-taken');
+ created.push(dest);
+ fs.writeFileSync(path.join(dest, 'library.json'), '{}');
+ assert.throws(() => store.migrateTo(dest), /已包含书库索引/);
+});
+
+test('rollbackMigration 回到原目录且不留残File', () => {
+ const src = freshRoot('mig-rb');
+ const shelf = store.addShelf('回滚书架');
+ const p = path.join(src, 'files', 'r.pdf');
+ fs.writeFileSync(p, 'data');
+ store.add({ title: 'R', shelfId: shelf.id, files: [{ path: p }] });
+
+ const dest = tmpDir('mig-rb-dest');
+ created.push(dest);
+ store.migrateTo(dest);
+ store.rollbackMigration();
+
+ assert.strictEqual(store.getRoot(), path.resolve(src));
+ assert.ok(fs.existsSync(p), '回滚后源文件应还在');
+ assert.ok(!fs.existsSync(path.join(dest, 'library.json')), '目标目录索引未清理');
+ assert.strictEqual(store.list().length, 1);
+ assert.strictEqual(store.list()[0].shelfId, shelf.id);
+ assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['回滚书架']);
+});
+
+test('importLegacy 正确复制相对路径封面', () => {
+ const legacy = tmpDir('legacy-relative-cover');
+ created.push(legacy);
+ fs.mkdirSync(path.join(legacy, 'covers'), { recursive: true });
+ fs.writeFileSync(path.join(legacy, 'covers', 'old.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify([{
+ id: 'legacy-book',
+ title: 'Legacy',
+ cover: 'covers/old.jpg',
+ files: []
+ }]));
+
+ const root = freshRoot('legacy-relative-dest');
+ assert.strictEqual(store.importLegacy(legacy).imported, 1);
+ const imported = store.get('legacy-book');
+ assert.ok(imported.cover.startsWith(path.join(root, 'covers')), imported.cover);
+ assert.ok(fs.existsSync(imported.cover));
+});
+
+test('update 修改字段并刷新 updatedAt', () => {
+ freshRoot('update');
+ const it = store.add({ title: '旧' });
+ const out = store.update(it.id, { title: '新', tags: ['t'] });
+ assert.strictEqual(out.title, '新');
+ assert.deepStrictEqual(out.tags, ['t']);
+ assert.throws(() => store.update('nope', {}), /条目不存在/);
+});
+
+test('v1 和 v2 索引透明迁移到 v4 并保留条目与标签目录', () => {
+ for (const fixture of [
+ {
+ tag: 'schema-v1',
+ data: [{ id: 'v1', title: '旧数组', custom: { kept: true }, tags: [' A ', 'a', ''] }]
+ },
+ {
+ tag: 'schema-v2',
+ data: {
+ version: 2,
+ items: [{ id: 'v2', title: '旧对象', custom: { kept: true }, tags: ['B'], shelfId: 'missing' }]
+ }
+ }
+ ]) {
+ const root = freshRoot(fixture.tag);
+ fs.writeFileSync(path.join(root, 'library.json'), JSON.stringify(fixture.data));
+ store.init(root);
+ const item = store.list()[0];
+ assert.deepStrictEqual(item.custom, { kept: true });
+ assert.strictEqual(item.shelfId, null);
+ assert.strictEqual(item.tags.length, 1);
+
+ store.update(item.id, { title: item.title });
+ const persisted = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
+ assert.strictEqual(persisted.version, 4);
+ assert.deepStrictEqual(persisted.shelves, []);
+ assert.strictEqual(persisted.tags.length, 1);
+ assert.strictEqual(persisted.tags[0].name, item.tags[0]);
+ assert.deepStrictEqual(persisted.items[0].custom, { kept: true });
+ }
+});
+
+test('书架 CRUD 强制唯一非空名称并返回深拷贝', () => {
+ freshRoot('shelf-crud');
+ const shelf = store.addShelf({ name: ' 技术 ' });
+ assert.match(shelf.id, /^shelf_[a-f0-9]{24}$/);
+ assert.strictEqual(shelf.name, '技术');
+ assert.ok(Number.isFinite(shelf.createdAt));
+ assert.ok(Number.isFinite(shelf.updatedAt));
+ assert.throws(() => store.addShelf(' '), /不能为空/);
+ assert.throws(() => store.addShelf('技术'), /已存在/);
+
+ const listed = store.listShelves();
+ listed[0].name = '被外部修改';
+ listed.push({ id: 'fake', name: '假的' });
+ assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['技术']);
+
+ const updated = store.updateShelf(shelf.id, { name: ' 文学 ' });
+ assert.strictEqual(updated.name, '文学');
+ assert.strictEqual(updated.createdAt, shelf.createdAt);
+ assert.ok(updated.updatedAt >= shelf.updatedAt);
+ updated.name = '再次外部修改';
+ assert.strictEqual(store.listShelves()[0].name, '文学');
+ assert.throws(() => store.updateShelf('missing', { name: 'X' }), /不存在/);
+
+ const other = store.addShelf('Research');
+ assert.throws(() => store.addShelf(' research '), /已存在/);
+ assert.throws(() => store.updateShelf(other.id, { name: ' 文学 ' }), /已存在/);
+});
+
+test('删除书架只清空条目 shelfId 且组织变更触发通知', () => {
+ freshRoot('shelf-remove');
+ let changes = 0;
+ store.setChangeListener(() => { changes++; });
+ try {
+ const shelf = store.addShelf('待整理');
+ const book = store.add({ title: '保留我' });
+ store.update(book.id, { shelfId: shelf.id, tags: [' A ', 'a', 'B'] });
+ assert.strictEqual(changes, 2, '书架添加和组织更新均应通知');
+ assert.strictEqual(store.get(book.id).shelfId, shelf.id);
+
+ assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: true });
+ assert.strictEqual(changes, 3);
+ assert.strictEqual(store.list().length, 1, '删除书架不应删除书籍');
+ assert.strictEqual(store.get(book.id).shelfId, null);
+ assert.deepStrictEqual(store.get(book.id).tags, ['A', 'B']);
+ assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: false });
+ assert.strictEqual(changes, 3, '重复删除不存在的书架不应通知');
+ } finally {
+ store.setChangeListener(null);
+ }
+});
+
+test('条目组织字段归一化并限制标签数量和长度', () => {
+ freshRoot('organization-normalize');
+ const shelf = store.addShelf('有效书架');
+ const manyTags = Array.from({ length: 60 }, (_, i) => ` tag-${i} `);
+ const book = store.add({
+ title: '组织',
+ shelfId: shelf.id,
+ tags: [' Foo ', 'foo', null, '', 'x'.repeat(80), ...manyTags]
+ });
+ assert.strictEqual(book.shelfId, shelf.id);
+ assert.strictEqual(book.tags[0], 'Foo');
+ assert.strictEqual(book.tags[1].length, 64);
+ assert.strictEqual(book.tags.length, 50);
+
+ const invalid = store.update(book.id, { shelfId: 'not-a-shelf', tags: 'not-an-array' });
+ assert.strictEqual(invalid.shelfId, null);
+ assert.deepStrictEqual(invalid.tags, []);
+});
+
+test('listTags 合并大小写、保留稳定 ID 并按数量和中文名称排序', () => {
+ freshRoot('tag-catalog');
+ store.add({ title: '一', tags: [' 科学 ', 'SCIENCE', '历史'] });
+ store.add({ title: '二', tags: ['科学', 'science', '文学'] });
+ store.add({ title: '三', tags: ['Science'] });
+
+ const actual = store.listTags();
+ assert.ok(actual.every((tag) => /^tag_[a-f0-9]{24}$/.test(tag.id)));
+ assert.deepStrictEqual(actual.slice(0, 2).map(({ name, count }) => ({ name, count })), [
+ { name: 'SCIENCE', count: 3 },
+ { name: '科学', count: 2 }
+ ]);
+ const tied = actual.slice(2).map(({ name, count }) => ({ name, count }));
+ assert.deepStrictEqual(
+ tied,
+ [{ name: '历史', count: 1 }, { name: '文学', count: 1 }]
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' }))
+ );
+});
+
+test('importLegacy 保留书架、标签并将同名书架映射到现有书架', () => {
+ const legacy = tmpDir('legacy-shelves');
+ created.push(legacy);
+ fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify({
+ version: 3,
+ shelves: [
+ { id: 'old-shared', name: ' 已有 ', createdAt: 10, updatedAt: 20 },
+ { id: 'old-new', name: '新书架', createdAt: 30, updatedAt: 40 }
+ ],
+ items: [
+ { id: 'legacy-shared-book', title: '共享', shelfId: 'old-shared', tags: [' A ', 'a'] },
+ { id: 'legacy-new-book', title: '新增', shelfId: 'old-new', tags: [' B '] }
+ ]
+ }));
+
+ freshRoot('legacy-shelves-dest');
+ const existing = store.addShelf('已有');
+ assert.strictEqual(store.importLegacy(legacy).imported, 2);
+ const importedShelves = store.listShelves();
+ assert.deepStrictEqual(importedShelves.map((entry) => entry.name), ['已有', '新书架']);
+ assert.strictEqual(importedShelves[1].createdAt, 30);
+ assert.strictEqual(store.get('legacy-shared-book').shelfId, existing.id);
+ assert.strictEqual(store.get('legacy-new-book').shelfId, importedShelves[1].id);
+ assert.deepStrictEqual(store.get('legacy-shared-book').tags, ['A']);
+
+ const raw = JSON.parse(fs.readFileSync(path.join(store.getRoot(), 'library.json'), 'utf8'));
+ assert.strictEqual(raw.version, 4);
+ assert.strictEqual(raw.shelves.length, 2);
+ assert.deepStrictEqual(raw.tags.map((tag) => tag.name).sort(), ['A', 'B']);
+});
diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js
new file mode 100644
index 0000000..87d91bc
--- /dev/null
+++ b/src/_test/ui.test.js
@@ -0,0 +1,521 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+
+const utilFile = path.join(__dirname, '..', 'ui', 'util.js');
+const libFile = path.join(__dirname, '..', 'ui', 'views', 'library.js');
+const utilSrc = fs.readFileSync(utilFile, 'utf8');
+
+// util.js 只做 window.X = ... 赋值,没有加载期副作用,
+// 因此可以整体求值拿到真实实现(DOM 依赖都在调用时才触发)。
+function loadUtil() {
+ const win = {};
+ const store = new Map();
+ win.localStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v))
+ };
+ new Function('window', 'localStorage', 'document', utilSrc)(win, win.localStorage, undefined);
+ return win;
+}
+
+// style="..." 里的值会先被 HTML 解码,再交给 CSS 解析
+function htmlDecode(s) {
+ return s.replace(/'/g, "'").replace(/"/g, '"')
+ .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
+}
+
+test('escapeHtml 覆盖全部危险字符', () => {
+ const { escapeHtml } = loadUtil();
+ assert.strictEqual(escapeHtml(`&'`), '<a href="x">&'');
+ assert.strictEqual(escapeHtml(null), '');
+ assert.strictEqual(escapeHtml(undefined), '');
+ assert.strictEqual(escapeHtml(0), '0');
+});
+
+test('coverStyle 阻断 style 属性逃逸', () => {
+ const { coverStyle } = loadUtil();
+ const out = coverStyle('https://evil/a.jpg") onerror="alert(1)');
+ assert.ok(!out.includes('"'), '裸引号泄漏: ' + out);
+ assert.ok(out.includes('"'), '未做 HTML 转义: ' + out);
+});
+
+test('coverStyle 阻断 CSS 串逃逸', () => {
+ const { coverStyle } = loadUtil();
+ // 浏览器会先 HTML 解码属性值,再按 CSS 解析,这里模拟同样的两步
+ const css = htmlDecode(coverStyle("https://evil/a.jpg'); background:url('x"));
+ const inner = css.replace(/^background-image:url\('/, '').replace(/'\)$/, '');
+ assert.ok(!/(^|[^\\])'/.test(inner), 'CSS 单引号未转义,可提前闭合 url(): ' + css);
+ assert.ok(!/(^|[^\\])\)/.test(inner), 'CSS 右括号未转义: ' + css);
+});
+
+test('coverStyle 拒绝换行注入', () => {
+ const { coverStyle } = loadUtil();
+ assert.strictEqual(coverStyle('https://x/a.jpg\n background:red'), '');
+});
+
+test('coverStyle 正常输入仍可用', () => {
+ const { coverStyle } = loadUtil();
+ assert.strictEqual(coverStyle(''), '');
+ // 转义后浏览器实际解析到的地址才是关注点
+ const remote = htmlDecode(coverStyle('https://x/a.jpg')).replace(/\\(.)/g, '$1');
+ assert.strictEqual(remote, "background-image:url('https://x/a.jpg')");
+ const local = htmlDecode(coverStyle('C:\\books\\c.jpg')).replace(/\\([('")])/g, '$1');
+ assert.ok(local.includes('file:///C:/books/c.jpg'), local);
+});
+
+test('formatDate 补零', () => {
+ const { formatDate } = loadUtil();
+ assert.strictEqual(formatDate(0), '');
+ assert.strictEqual(formatDate(new Date(2024, 0, 5).getTime()), '2024-01-05');
+});
+
+test('enabledSources 存取;损坏数据回退为 null', () => {
+ const win = loadUtil();
+ assert.strictEqual(win.getEnabledSources(), null);
+ win.setEnabledSources(['arxiv', 'pmc']);
+ assert.deepStrictEqual(win.getEnabledSources(), ['arxiv', 'pmc']);
+ win.localStorage.setItem('enabledSources', '{坏json');
+ assert.strictEqual(win.getEnabledSources(), null, '损坏数据应回退而不是抛错');
+});
+
+test('添加本地内容支持文件、文件夹和上级目录分类选项', () => {
+ const src = fs.readFileSync(libFile, 'utf8');
+ assert.match(src, /name="localImportSource" value="files"/);
+ assert.match(src, /name="localImportSource" value="folder"/);
+ assert.match(src, /window\.api\.library\.pickLocal\(source\)/);
+ assert.match(src, /value="shelf"[\s\S]+上一级目录作为书架/);
+ assert.match(src, /value="tag"[\s\S]+上一级目录作为标签/);
+ assert.match(src, /window\.api\.library\.importLocal\(selection\.selectionId,\s*options\)/);
+});
+
+test('写进 HTML 的字段插值都过 escapeHtml', () => {
+ for (const f of ['views/browse.js', 'views/library.js']) {
+ const src = fs.readFileSync(path.join(__dirname, '..', 'ui', f), 'utf8');
+ // 只看真正拼 HTML 的行(含标签),DOM 选择器之类的插值不在此列
+ const bad = [];
+ src.split('\n').forEach((line, i) => {
+ if (!/<[a-z]/i.test(line)) return;
+ for (const m of line.match(/\$\{(?!escapeHtml|coverStyle)[^}]*\}/g) || []) {
+ if (/^\$\{(it|d|f|l|s|e|b)\.[a-zA-Z_]+\}$/.test(m)) bad.push(`${f}:${i + 1} ${m}`);
+ }
+ });
+ assert.deepStrictEqual(bad, [], `存在未转义的 HTML 插值:\n${bad.join('\n')}`);
+ }
+});
+
+test('index.html 保留 CSP 且未开启 nodeIntegration', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ assert.ok(/Content-Security-Policy/.test(html), '缺少 CSP');
+ assert.ok(/default-src 'self'/.test(html));
+ const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
+ assert.ok(/contextIsolation:\s*true/.test(main));
+ assert.ok(/nodeIntegration:\s*false/.test(main));
+});
+
+test('我的笔记页可新建关联或无关联笔记', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
+ assert.match(html, /data-tab="notes">我的笔记);
+ assert.match(html, /id="addGlobalNoteBtn"/);
+ assert.match(notes, /window\.api\.library\.list\(\)/);
+ assert.match(notes, /source:\s*'manual'/);
+ assert.match(notes, /不关联书籍<\/option>/);
+ assert.match(notes, /window\.api\.reader\.addNote\(entryId,\s*note\)/);
+ assert.match(notes, /window\.api\.reader\.addStandaloneNote\(note\)/);
+ assert.match(notes, /note\.associated === false\s*\?\s*'未关联书籍'/);
+});
+
+test('书库支持标题作者模糊搜索、侧栏滚动和稳定封面占位卡', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const library = fs.readFileSync(libFile, 'utf8');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
+ assert.match(html, /id="librarySearchInput"[^>]+搜索标题或作者/);
+ assert.match(html, /id="librarySearchBtn"/);
+ assert.match(html, /id="libraryClearSearchBtn"/);
+ assert.match(html, /id="sortSelect"[\s\S]*value="recent">最近阅读/);
+ assert.match(library, /recent:\s*\(a,\s*b\)[\s\S]*lastReadAt/);
+ assert.match(library, /function matchesSearch\(item, query\)/);
+ assert.match(library, /item\.authors/);
+ assert.match(library, /isSubsequence/);
+ assert.match(library, /function reconcileCards/);
+ assert.doesNotMatch(library, /grid\.innerHTML\s*=\s*items\.map/);
+ const sidebarRule = css.match(/\.library-sidebar\s*\{([^}]*)\}/);
+ assert.ok(sidebarRule);
+ assert.match(sidebarRule[1], /max-height:\s*calc\(100vh - 84px\)/);
+ assert.match(sidebarRule[1], /overflow-y:\s*auto/);
+ assert.match(css, /\.card:hover \.card-cover:not\(\[data-cover-state="pending"\]\)/);
+ assert.match(library, /data-cover-state="\$\{it\.cover \? 'ready' : 'pending'\}"/);
+});
+
+test('书库页提供可管理标签目录和整理多选下拉', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const library = fs.readFileSync(libFile, 'utf8');
+ assert.match(html, /id="libraryShelfList"/);
+ assert.match(html, /id="libraryTagList"/);
+ assert.match(html, /id="addTagBtn"/);
+ assert.match(library, /window\.api\.library\.listShelves\(\)/);
+ assert.match(library, /window\.api\.library\.addTag\(\{ name \}\)/);
+ assert.match(library, /window\.api\.library\.updateTag\(tag\.id/);
+ assert.match(library, /window\.api\.library\.removeTag\(tag\.id\)/);
+ assert.match(library, /cardAction\('organize'/);
+ assert.match(library, / {
+ const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
+ assert.match(browse, /createDownloadProgress/);
+ assert.match(browse, /updateDownloadProgress/);
+ assert.match(browse, /classList\.add\('downloaded'\)/);
+ const rule = css.match(/\.dl-btn\.downloaded\s*\{([^}]*)\}/);
+ assert.ok(rule, '缺少下载完成按钮样式');
+ assert.match(rule[1], /background:\s*var\(--green\)/);
+ assert.doesNotMatch(rule[1], /background:\s*var\(--accent\)/);
+ assert.match(rule[1], /color:\s*#07130b/);
+});
+
+test('主窗口在设置旁提供持久化明暗主题切换', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
+ const themeAt = html.indexOf('id="uiThemeBtn"');
+ const settingsAt = html.indexOf('data-tab="settings"');
+ assert.ok(themeAt >= 0 && themeAt < settingsAt, '主题按钮不在设置按钮旁边');
+ assert.match(app, /window\.api\.ui\.getTheme\(\)/);
+ assert.match(app, /window\.api\.ui\.setTheme\(next\)/);
+ assert.match(css, /:root\[data-ui-theme="light"\]/);
+ assert.match(css, /--bg:\s*#f4f7fb/);
+});
+
+test('人民阅读器品牌与主题图标显示在界面左上角', () => {
+ for (const file of ['index.html', 'reader.html']) {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', file), 'utf8');
+ assert.match(html, /PeopleLib<\/title>/);
+ assert.match(html, /人民阅读器/);
+ assert.match(html, /brand-logo-dark[^>]+icons\/dist\/dark\/icon-32\.png/);
+ assert.match(html, /brand-logo-light[^>]+icons\/dist\/light\/icon-32\.png/);
+ }
+});
+
+test('阅读器控件隔离正文选择并提供 PDF 适宽、拖拽和文本选择工具', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
+ const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
+ const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
+ const pdfWorker = fs.readFileSync(path.join(__dirname, '..', 'ui', 'vendor', 'pdf.worker.range.mjs'), 'utf8');
+ const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
+ assert.match(html, /id="fitWidthBtn"[\s\S]*aria-label="适应内容宽度"/);
+ assert.match(html, /data-annotation-tool="pan"[\s\S]*data-annotation-tool="text-select"/);
+ assert.match(css, /button,[\s\S]*\.statusbar,[\s\S]*user-select:\s*none/);
+ assert.match(shell, /isReaderControlTarget/);
+ assert.match(shell, /fitPdfWidth/);
+ assert.match(pdf, /function fitWidthScale/);
+ assert.match(pdf, /className = 'endOfContent'/);
+ assert.match(pdf, /const endPage = pageOfNode\(range\.endContainer\)/);
+ assert.match(pdf, /pdfx-tool-pan/);
+ assert.match(pdf, /extends pdfjs\.PDFDataRangeTransport/);
+ assert.match(pdf, /pdf\.worker\.range\.mjs/);
+ assert.match(pdf, /disableAutoFetch\s*=\s*true/);
+ assert.match(pdfWorker, /super\(new Uint8Array\(0\), 0, length, null\)/);
+ assert.doesNotMatch(pdfWorker, /super\(new Uint8Array\(length\), 0, length, null\)/);
+ assert.match(pdfWorker, /MAX_SPARSE_PDF_CACHE_BYTES = 256 \* 1024 \* 1024/);
+ assert.match(pdfWorker, /MAX_GROUPED_RANGE_CHUNKS = 4/);
+ assert.match(pdfWorker, /offset = offset \* 256 \+ offsetByte/);
+ assert.match(pdfWorker, /_loadedChunks\.delete\(chunk\)/);
+ // 稀疏基础缓冲区是空的,字体哈希不能再直接按 stream.bytes.buffer 建视图,
+ // 否则字体会静默变成不可见的 ErrorFont
+ assert.match(pdfWorker, /stream\.getByteRange\(stream\.start, stream\.end\)/);
+ assert.doesNotMatch(pdfWorker, /new Uint8Array\(stream\.bytes\.buffer, stream\.start, stream\.end - stream\.start\)/);
+ // 256 MB 以内仍用官方 worker,只有超出才启用稀疏 worker
+ assert.match(pdf, /STANDARD_WORKER_MAX_BYTES/);
+ assert.match(pdf, /SPARSE_WORKER_URL/);
+ assert.match(pdf, /this\.active < 8/);
+ assert.match(pdf, /Promise\.race\(\[task\.promise, rangeFailurePromise\]\)/);
+ assert.match(shell, /openPdfRangeSource/);
+ assert.match(shell, /api\.reader\.rangeRead/);
+ assert.match(preload, /rangeOpen:[\s\S]*reader:rangeOpen/);
+ assert.match(preload, /rangeRead:[\s\S]*reader:rangeRead/);
+ assert.match(preload, /rangeClose:[\s\S]*reader:rangeClose/);
+});
+
+test('AI 助手提供受限图像上下文和可扩展 OCR 契约', () => {
+ const index = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
+ const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
+ const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
+ const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
+ const ocr = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'ocr-provider.mjs'), 'utf8');
+ const contract = fs.readFileSync(path.join(__dirname, '..', 'reader', 'visual-context.js'), 'utf8');
+ const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
+ const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
+ const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
+ const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
+ assert.match(index, /id="aiProtocol"[\s\S]*value="anthropic"[\s\S]*value="openai-responses"[\s\S]*value="chat-completions"/);
+ assert.match(index, /id="aiVision"[^>]*type="checkbox"/);
+ assert.match(app, /protocol:\s*\$\('aiProtocol'\)\.value/);
+ assert.match(reader, /value="page-image"[\s\S]*value="region-image"/);
+ assert.match(reader, /id="aiVisualCard"[\s\S]*id="aiOcrBtn"[\s\S]*disabled/);
+ assert.deepStrictEqual(
+ [...reader.matchAll(/data-ai-task="([^"]+)"/g)].map((match) => match[1]),
+ ['summarize']
+ );
+ assert.match(shell, /function beginVisualSelection/);
+ assert.match(shell, /function confirmVisualSelection/);
+ assert.match(shell, /toAiVisualContext/);
+ assert.match(pdf, /async function captureVisual/);
+ assert.match(pdf, /function visualPageAtPoint/);
+ assert.match(epub, /function visualViewportRect/);
+ assert.match(ocr, /function registerOcrProvider/);
+ assert.match(ocr, /function recognizeOcr/);
+ assert.match(ocr, /signal:\s*options\.signal/);
+ assert.match(contract, /MAX_IMAGE_BYTES\s*=\s*3\s*\*\s*1024\s*\*\s*1024/);
+ assert.match(contract, /MAX_VISUAL_CONTEXTS\s*=\s*1/);
+ assert.match(contract, /图像内容与声明尺寸不匹配/);
+ assert.match(client, /type:\s*'image_url'/);
+ assert.match(client, /type:\s*'image'[\s\S]*type:\s*'base64'[\s\S]*media_type:/);
+ assert.match(client, /type:\s*'input_image'/);
+ assert.match(client, /当前模型配置未启用图像输入/);
+ assert.match(app, /模型已配置[\s\S]*尚缺 API Key/);
+ assert.match(app, /已保存的 API Key 无法读取,请重新输入/);
+ assert.match(shell, /模型已配置,但尚缺 API Key/);
+ assert.match(shell, /模型已配置,但已保存的 API Key 无法读取/);
+ assert.match(preload, /onChanged:\s*\(cb\)[\s\S]*ipcRenderer\.on\('ai:changed'/);
+ assert.match(shell, /api\.ai\.onChanged\(\(\)\s*=>\s*refreshAiStatus\(\)\)/);
+ assert.match(preload, /function captureReaderRect\(rect\)/);
+ assert.match(preload, /document\.getElementById\('docArea'\)/);
+ assert.match(preload, /captureRect:\s*\(rect\)\s*=>\s*captureReaderRect\(rect\)/);
+ assert.match(main, /ipcMain\.handle\('reader:captureRect'/);
+ assert.match(main, /截图区域无效或超出阅读器窗口/);
+});
+
+test('AI 上下文提供无需选中的当前页与全文范围', () => {
+ const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
+ const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
+ const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
+ const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
+
+ assert.match(reader, /全文<\/option>/);
+ assert.doesNotMatch(reader, /value="chapter"/);
+
+ // 只有 selection 需要选区,page/document 直接走 textOf
+ assert.match(shell, /if \(scope === 'selection'\)[\s\S]{0,400}请先在正文中选中文本/);
+ assert.match(shell, /scope === 'page' \? 'page' : 'document'/);
+
+ // 全文必须提示可能超限,并且始终弹确认框
+ assert.match(shell, /可能超过模型限制/);
+ assert.match(shell, /全文可能超过模型的上下文限制/);
+ assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
+
+ // 旧设置迁移,避免升级后回落成 selection
+ assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
+ assert.match(shell, /api\.settings\.set\('reader\.aiScope', 'document'\)/);
+
+ // 适配器真的取整本,而不是当前页 ±1
+ assert.match(pdf, /if \(span !== 'document'\) return pageText\(page\)/);
+ assert.match(pdf, /for \(let i = 1; i <= pageCount; i\+\+\)/);
+ assert.match(epub, /if \(span === 'document'\)[\s\S]{0,400}chapter < spine\.length/);
+});
+
+test('AI 图像上下文按更小的目标体积压缩且只用 JPEG', () => {
+ const visual = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'visual-context.mjs'), 'utf8');
+ assert.match(visual, /MAX_CAPTURE_DIMENSION = 1600/);
+ assert.match(visual, /TARGET_CAPTURE_BYTES = 400 \* 1024/);
+ assert.match(visual, /const qualities = \[0\.82, 0\.74, 0\.66, 0\.58\]/);
+ assert.match(visual, /bytes <= TARGET_CAPTURE_BYTES/);
+ // 缩到 800px 就停手,避免文字页被压糊
+ assert.match(visual, /<= 800\) break/);
+ // 只保留一条 JPEG 编码路径,不做格式回退
+ assert.deepStrictEqual([...visual.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]), ['image/jpeg']);
+});
+
+test('AI 回答使用固定版本 Markdown-it 和 DOMPurify 安全渲染', () => {
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
+ const renderer = fs.readFileSync(path.join(__dirname, '..', 'ui', 'ai-markdown.js'), 'utf8');
+ const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
+ assert.strictEqual(pkg.devDependencies['markdown-it'], '15.0.0');
+ assert.strictEqual(pkg.devDependencies.dompurify, '3.4.12');
+ assert.match(html, /vendor\/purify\.min\.js[\s\S]*vendor\/markdown-it\.min\.js[\s\S]*ai-markdown\.js/);
+ assert.match(renderer, /html:\s*false/);
+ assert.match(renderer, /purifier\.sanitize/);
+ assert.match(renderer, /renderer\.rules\.image/);
+ assert.match(renderer, /data-external-url/);
+ assert.match(renderer, /MAX_MARKDOWN_LENGTH\s*=\s*256\s*\*\s*1024/);
+ assert.match(shell, /scheduleAiOutput/);
+ assert.match(shell, /window\.AiMarkdown\.externalUrl/);
+ assert.match(shell, /addEventListener\('auxclick'/);
+ assert.match(css, /\.ai-output pre[\s\S]*overflow:\s*auto/);
+ for (const file of [
+ 'vendor/markdown-it.min.js',
+ 'vendor/markdown-it.LICENSE.txt',
+ 'vendor/purify.min.js',
+ 'vendor/DOMPurify.LICENSE.txt'
+ ]) {
+ assert.ok(fs.existsSync(path.join(__dirname, '..', 'ui', file)), `${file} 未随应用提供`);
+ }
+});
+
+test('安装包和可执行文件保留 PeopleLib 产品名', () => {
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
+ const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
+ const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
+ assert.strictEqual(pkg.build.productName, 'PeopleLib');
+ assert.strictEqual(pkg.build.portable.artifactName, 'PeopleLib-${version}.exe');
+ assert.match(main, /app\.setName\('PeopleLib'\)/);
+ assert.match(build, /const PRODUCT = pkg\.productName \|\| 'PeopleLib'/);
+});
+
+test('设置关于页与 README 列出书库和内置阅读格式', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
+ assert.match(html, /关于 PeopleLib/);
+ assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3/);
+ assert.match(html, /书库导入与管理[\s\S]*TXT、DJVU、FB2、CBZ、CBR/);
+ assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
+ assert.match(readme, /## 支持格式/);
+ assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
+ assert.match(readme, /TXT \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
+});
+
+test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
+ const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
+ const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
+ const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.mjs'), 'utf8');
+ const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
+ assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
+ assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/);
+ assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
+ assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
+ assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
+ assert.match(adapter, /该 MOBI\/AZW 图书有 DRM 保护/);
+ assert.match(shell, /使用系统应用打开/);
+ assert.match(build, /node_modules', 'foliate-js'/);
+});
+
+test('书库卡片操作使用带悬浮提示的纯图标按钮', () => {
+ const library = fs.readFileSync(libFile, 'utf8');
+ assert.match(library, /const CARD_ICONS =/);
+ assert.match(library, /class="\$\{primary \? 'open-btn ' : ''\}icon-action"/);
+ assert.match(library, /title="\$\{label\}" aria-label="\$\{label\}"/);
+ for (const action of ['read', 'open', 'reveal', 'page', 'organize', 'remove']) {
+ assert.match(library, new RegExp(`cardAction\\('${action}'`));
+ }
+});
+
+test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版', () => {
+ const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
+ const rich = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.js'), 'utf8');
+ const mixed = fs.readFileSync(path.join(__dirname, '..', 'ui', 'mixed-note.js'), 'utf8');
+ const canvas = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-note.mjs'), 'utf8');
+ const canvasFlow = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-flow.mjs'), 'utf8');
+ const richCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.css'), 'utf8');
+ const appCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
+ const readerCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
+ const readerHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
+ const indexHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const store = fs.readFileSync(path.join(__dirname, '..', 'reader', 'store.js'), 'utf8');
+ const assets = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-assets.js'), 'utf8');
+ const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
+ assert.match(notes, /id="newNoteRich"/);
+ assert.match(notes, /id="noteEditRich"/);
+ assert.match(notes, /window\.MixedNote\.mount/);
+ assert.match(notes, /选择笔记类型/);
+ assert.match(notes, /noteType/);
+ assert.match(readerHtml, /id="noteRichEditor"/);
+ assert.match(indexHtml, /vendor\/quill\/quill\.js/);
+ assert.match(indexHtml, /vendor\/quill\/quill\.snow\.css/);
+ assert.match(readerHtml, /vendor\/jspdf\.umd\.min\.js/);
+ assert.match(readerHtml, /
+
+