feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读

笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

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

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

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

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
This commit is contained in:
lofyer
2026-08-04 16:19:06 +08:00
parent 522b0f74a5
commit 0fd7c59e08
68 changed files with 11721 additions and 301 deletions
+862
View File
@@ -0,0 +1,862 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const sessionsPath = require.resolve('../reader/ai-sessions.js');
const imagesPath = require.resolve('../reader/ai-images.js');
const dirs = [];
function tmp(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
dirs.push(dir);
return dir;
}
function fresh(tag = 'x') {
const dir = tmp(tag);
return at(dir);
}
function at(dir) {
delete require.cache[sessionsPath];
delete require.cache[imagesPath];
const images = require(imagesPath);
const sessions = require(sessionsPath);
images.init(dir);
sessions.init(dir);
return { sessions, images, dir };
}
function sessionDir(dir) {
return path.join(dir, 'reader-ai-sessions');
}
function sessionFile(dir, id) {
return path.join(sessionDir(dir), `${id}.json`);
}
function imageDir(dir) {
return path.join(dir, 'reader-ai-images');
}
// 最小可解码 JPEGSOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
function jpeg(seed, size = 64) {
const bytes = Buffer.alloc(size, seed & 0xff);
bytes[0] = 0xff;
bytes[1] = 0xd8;
bytes[2] = 0xff;
bytes[3] = 0xe0;
bytes[size - 2] = 0xff;
bytes[size - 1] = 0xd9;
return bytes;
}
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
function countWrites(matcher, fn) {
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
try { fn(); } finally { fs.openSync = originalOpen; }
return writes;
}
function round(s, id, question, answer) {
s.appendUser(id, { text: question, task: 'ask' });
const placeholder = s.appendAssistant(id, {});
return s.finishAssistant(id, placeholder.id, { text: answer });
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
// --- 基本读写 ---
test('会话按 per-session 文件落盘,索引可枚举', () => {
const { sessions, dir } = fresh('basic');
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
const b = sessions.create({ entryId: 'e2' });
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
assert.strictEqual(a.entryId, 'e1');
assert.strictEqual(b.entryId, 'e2');
const files = fs.readdirSync(sessionDir(dir)).sort();
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
'每个会话应独占一个文件,加上一份索引');
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
});
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
const { sessions } = fresh('global');
const g = sessions.create({});
assert.strictEqual(g.entryId, 'system:global-chat');
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
});
test('list 按 pinned desc, updatedAt desc 排序', () => {
const { sessions } = fresh('sort');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const c = sessions.create({ title: 'c' });
sessions.rename(b.id, 'b2');
sessions.setPinned(a.id, true);
const ids = sessions.list().map((row) => row.id);
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
});
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
const { sessions } = fresh('round');
const meta = sessions.create({ entryId: 'e1' });
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
const placeholder = sessions.appendAssistant(meta.id, {});
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
assert.strictEqual(read.messages[1].text, '这是回答');
assert.strictEqual(read.meta.messageCount, 2);
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
});
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
const { sessions } = fresh('finish');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: 'q' });
const p1 = sessions.appendAssistant(meta.id, {});
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
assert.strictEqual(cancelled.cancelled, true);
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
sessions.appendUser(meta.id, { text: 'q2' });
const p2 = sessions.appendAssistant(meta.id, {});
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
assert.strictEqual(failed.error, '接口超时');
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
});
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
const { sessions, dir } = fresh('lifecycle');
const meta = sessions.create({ entryId: 'e1', title: '保留' });
round(sessions, meta.id, '问', '答');
const cleared = sessions.clear(meta.id);
assert.strictEqual(cleared.messageCount, 0);
assert.strictEqual(cleared.droppedMessages, 0);
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
assert.strictEqual(sessions.list().length, 1);
assert.strictEqual(sessions.remove(meta.id), true);
assert.strictEqual(sessions.remove(meta.id), false);
assert.strictEqual(sessions.list().length, 0);
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
});
test('messages 支持 limit 与 before 游标翻页', () => {
const { sessions } = fresh('paging');
const meta = sessions.create({});
for (let i = 0; i < 5; i++) round(sessions, meta.id, `${i}`, `${i}`);
const tail = sessions.messages(meta.id, { limit: 4 });
assert.strictEqual(tail.messages.length, 4);
assert.strictEqual(tail.hasMore, true);
assert.strictEqual(tail.messages[3].text, '答4');
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
'before 必须是排他的,否则翻页会重复一条');
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
});
// --- 字段限长 ---
test('字段限长与 store.limitedString 一致:超长截断而不是抛错', () => {
const { sessions } = fresh('limits');
const L = sessions.LIMITS;
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
const p = sessions.appendAssistant(meta.id, {});
const done = sessions.finishAssistant(meta.id, p.id, {
text: '答'.repeat(L.messageText + 100),
error: '错'.repeat(L.errorText + 100)
});
assert.strictEqual(done.text.length, L.messageText, `assistant 文本应截到 messageText=${L.messageText}`);
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
assert.strictEqual(renamed.title.length, L.title);
});
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
const { sessions } = fresh('title');
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
const auto = sessions.create({});
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
});
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
const { sessions } = fresh('shape');
const meta = sessions.create({});
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'whatever' }
}), /会话上下文范围无效/);
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
const withContext = sessions.appendUser(meta.id, {
text: 'q',
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
});
assert.strictEqual(withContext.contextRef.chars, 4);
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
assert.strictEqual(withContext.contextRef.clipped, true);
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
}), /定位信息/);
});
// --- 上限策略 ---
test('会话数达上限抛中文错误,且不删除已有会话', () => {
const { sessions, dir } = fresh('total');
const total = sessions.LIMITS.sessionsTotal;
const ids = [];
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
const before = fs.readdirSync(sessionDir(dir)).length;
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
assert.strictEqual(sessions.list().length, total);
sessions.remove(ids[0]);
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
});
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
const { sessions } = fresh('evict');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
const first = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
for (let i = 0; i < max; i++) round(sessions, meta.id, `${i}`, `${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
assert.strictEqual(
read.meta.messageCount + read.meta.droppedMessages,
(max + 1) * 2,
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
);
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
}
});
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
test('淘汰后的中间态也不出现连续同角色', () => {
const { sessions } = fresh('evict-mid');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `${i}`, `${i}`);
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
sessions.appendUser(meta.id, { text: '新问题' });
const read = sessions.messages(meta.id, { limit: max });
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
}
assert.strictEqual(roles[0], 'user');
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
assert.strictEqual(roles[roles.length - 1], 'user');
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.strictEqual(
out.messages.length,
read.messages.length,
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
);
});
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
const { sessions, images } = fresh('img-evict');
const max = sessions.LIMITS.imagesPerSession;
const meta = sessions.create({});
const ids = [];
for (let i = 0; i <= max; i++) {
const put = images.put(jpeg(i + 1), 'image/jpeg');
ids.push(put.imageId);
sessions.appendUser(meta.id, {
text: `${i}`,
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
});
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: `${i}` });
}
const read = sessions.messages(meta.id, { limit: 500 });
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
});
// --- 文件隔离与写放大 ---
test('追加消息只重写自己那个会话文件', () => {
const { sessions, dir } = fresh('isolate');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const bFile = sessionFile(dir, b.id);
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
round(sessions, a.id, '问', '答');
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
round(sessions, a.id, '问2', '答2');
});
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
});
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
const { sessions, dir } = fresh('writes');
const meta = sessions.create({});
const file = sessionFile(dir, meta.id);
const isSession = (t) => t === `${file}.tmp`;
const oneRound = countWrites(isSession, () => {
sessions.appendUser(meta.id, { text: '问' });
const p = sessions.appendAssistant(meta.id, {});
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
});
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
// 对照:两轮就是四次,证明计数器没失灵
const twoRounds = countWrites(isSession, () => {
round(sessions, meta.id, '问2', '答2');
round(sessions, meta.id, '问3', '答3');
});
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
const idxFile = path.join(sessionDir(dir), 'index.json');
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
round(sessions, meta.id, '问4', '答4');
});
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
});
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
const { sessions, dir } = fresh('cache');
const meta = sessions.create({});
round(sessions, meta.id, '原始问题', '原始回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
const fixed = new Date(1700000000000);
fs.utimesSync(file, fixed, fixed);
const stat = fs.statSync(file);
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
raw.messages[0].text = '篡改问题';
const rewritten = JSON.stringify(raw, null, 2);
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
fs.writeFileSync(file, rewritten);
fs.utimesSync(file, fixed, fixed);
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
});
// --- 损坏自愈 ---
test('主文件损坏时从 .bak 恢复', () => {
const { sessions, dir } = fresh('bak');
const meta = sessions.create({ entryId: 'e1' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, '{ 这不是 JSON');
const read = sessions.messages(meta.id);
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
assert.strictEqual(read.messages[0].text, '问');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
});
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
const { sessions, dir } = fresh('corrupt');
const meta = sessions.create({ entryId: 'e1', title: '标题' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.writeFileSync(file, 'broken');
fs.writeFileSync(`${file}.bak`, 'also broken');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1);
round(sessions, meta.id, '新问', '新答');
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
});
test('index.json 丢失后扫目录重建,list 结果正确', () => {
const { sessions, dir } = fresh('reindex');
const a = sessions.create({ entryId: 'e1', title: '甲' });
const b = sessions.create({ entryId: 'e2', title: '乙' });
round(sessions, a.id, '问', '答');
const idxFile = path.join(sessionDir(dir), 'index.json');
fs.unlinkSync(idxFile);
const reloaded = at(dir).sessions;
const rows = reloaded.list();
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
const rowA = rows.find((r) => r.id === a.id);
assert.strictEqual(rowA.title, '甲');
assert.strictEqual(rowA.entryId, 'e1');
assert.strictEqual(rowA.messageCount, 2);
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
});
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
const { sessions, dir } = fresh('reindex2');
const a = sessions.create({ title: '甲' });
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
const reloaded = at(dir).sessions;
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
});
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
const { sessions, dir } = fresh('reindex3');
const a = sessions.create({ title: '甲' });
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions.push({ id: '../escape', title: '恶意' });
idx.sessions.push({ id: '__proto__', title: '污染' });
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const rows = at(dir).sessions.list();
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
});
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
const { sessions, dir } = fresh('bad-msg');
const meta = sessions.create({});
round(sessions, meta.id, '好问题', '好回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const read = at(dir).sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
assert.deepStrictEqual(read.messages[2].images, []);
assert.strictEqual(read.messages[2].task, null);
});
// --- ID 校验 ---
test('非法会话 ID 被拒,不产生任何文件', () => {
const { sessions, dir } = fresh('ids');
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
for (const id of bad) {
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
}
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
'被拒的 ID 不应在磁盘上留下文件');
});
test('非法 entryId 与 messageId 被拒', () => {
const { sessions } = fresh('ids2');
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
}
const meta = sessions.create({ entryId: 'e1' });
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
}
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
}
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
});
test('finishAssistant 不能跨会话认领占位消息', () => {
const { sessions } = fresh('cross');
const a = sessions.create({});
const b = sessions.create({});
sessions.appendUser(a.id, { text: 'q' });
const p = sessions.appendAssistant(a.id, {});
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
});
// --- historyFor ---
function history(sessions, id, budget) {
return sessions.historyFor(id, budget);
}
test('historyFor 始终 pin 首条 user 消息', () => {
const { sessions } = fresh('hist-pin');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
for (let i = 0; i < 20; i++) round(sessions, meta.id, `${i}`.repeat(20), `${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user');
assert.ok(out.messages[0].text.includes('正文'),
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
assert.ok(out.dropped > 0);
});
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
const { sessions } = fresh('hist-mark');
const meta = sessions.create({});
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
'不允许为了放标记而造出 system 之类的新角色');
});
test('historyFor 总字符不超过 maxChars', () => {
const { sessions } = fresh('hist-total');
const meta = sessions.create({});
for (let i = 0; i < 15; i++) round(sessions, meta.id, `${i}`.repeat(50), `${i}`.repeat(50));
for (const maxChars of [200, 500, 1200, 3000]) {
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
}
});
test('historyFor 单条超限中间挖空且保留尾部', () => {
const { sessions } = fresh('hist-clip');
const meta = sessions.create({});
const head = '开头标记';
const tail = '结尾标记';
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '答' });
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
const first = out.messages[0];
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
});
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
const { sessions, dir } = fresh('hist-lead');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user',
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
});
test('historyFor 不产生连续同角色消息', () => {
const { sessions, dir } = fresh('hist-roles');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const reloaded = at(dir).sessions;
for (const budget of [
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
]) {
const out = reloaded.historyFor(meta.id, budget);
const roles = out.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
}
if (roles.length) assert.strictEqual(roles[0], 'user');
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
}
});
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
const { sessions } = fresh('hist-dropped');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max; i++) round(sessions, meta.id, `${i}`, `${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.droppedMessages > 0);
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.ok(out.dropped >= read.meta.droppedMessages,
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
});
test('historyFor 在空会话与单条会话上不炸', () => {
const { sessions } = fresh('hist-edge');
const meta = sessions.create({});
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
sessions.appendUser(meta.id, { text: '只有一条' });
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
assert.strictEqual(out.messages[0].role, 'user');
});
// --- imageIds 与 GC ---
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
const { sessions, images, dir } = fresh('gc-scan');
const a = sessions.create({ title: '甲' });
const b = sessions.create({ title: '乙' });
const imgA = images.put(jpeg(11), 'image/jpeg');
const imgB = images.put(jpeg(22), 'image/jpeg');
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const reloaded = at(dir);
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
const ids = reloaded.sessions.imageIds();
assert.ok(ids.includes(imgB.imageId),
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
assert.ok(ids.includes(imgA.imageId));
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
});
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
const { sessions, images, dir } = fresh('gc-bak');
const meta = sessions.create({});
const img = images.put(jpeg(33), 'image/jpeg');
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, 'broken');
const reloaded = at(dir);
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
const pendingImg = images.put(jpeg(44), 'image/jpeg');
const s2 = fresh('gc-pending');
const m2 = s2.sessions.create({});
s2.sessions.appendUser(m2.id, { text: 'q' });
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
});
test('会话删除后其图像被 cleanup 回收', () => {
const { sessions, images, dir } = fresh('gc-remove');
const keep = sessions.create({ title: '留' });
const drop = sessions.create({ title: '删' });
const imgKeep = images.put(jpeg(55), 'image/jpeg');
const imgDrop = images.put(jpeg(66), 'image/jpeg');
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
sessions.remove(drop.id);
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
assert.strictEqual(removed, 1);
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
});
// --- ai-images ---
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
const { images } = fresh('img-mime');
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
});
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
const { images, dir } = fresh('img-dedup');
const bytes = jpeg(77, 1024);
const first = images.put(bytes, 'image/jpeg');
const second = images.put(Buffer.from(bytes), 'image/jpeg');
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
assert.strictEqual(images.totalBytes(), 1024);
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
const other = images.put(jpeg(78, 1024), 'image/jpeg');
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
assert.strictEqual(images.totalBytes(), 2048);
});
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
const { images } = fresh('img-read');
const bytes = jpeg(88, 256);
const { imageId } = images.put(bytes, 'image/jpeg');
assert.ok(images.read(imageId).equals(bytes));
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
}
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
});
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
const { images, dir } = fresh('img-cleanup');
const a = images.put(jpeg(1, 128), 'image/jpeg');
const b = images.put(jpeg(2, 128), 'image/jpeg');
const c = images.put(jpeg(3, 128), 'image/jpeg');
assert.strictEqual(images.cleanup([a.imageId]), 0,
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
assert.ok(images.read(a.imageId));
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
// 白名单里的垃圾值不应意外保住任何文件
images.put(jpeg(4, 128), 'image/jpeg');
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
});
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
const { images } = fresh('img-empty');
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([]), 0);
});
test('会话里的图像元数据被规范化,非法引用被拒', () => {
const { sessions, images } = fresh('img-meta');
const meta = sessions.create({});
const img = images.put(jpeg(99, 512), 'image/jpeg');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: '../escape' }]
}), /会话图像标识无效/);
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
}), /仅支持 JPEG/);
const stored = sessions.appendUser(meta.id, {
text: 'q',
images: [
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
]
});
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
assert.deepStrictEqual(stored.images[0], {
imageId: img.imageId,
mimeType: 'image/jpeg',
width: 100,
height: 200,
bytes: 512,
ocrIncluded: true
});
});
// --- 对账 ---
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
const { sessions } = fresh('orphan');
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
const gone = sessions.create({ entryId: 'e404', title: '已删' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
round(sessions, gone.id, '问', '答');
const report = sessions.orphanReport(['e1']);
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
assert.strictEqual(report[0].entryId, 'e404');
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
assert.strictEqual(report[0].messageCount, 2);
assert.ok(report[0].bytes > 0);
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
assert.ok(!report.some((r) => r.sessionId === inLib.id));
});
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
const { sessions } = fresh('forget');
const keep = sessions.create({ entryId: 'e1', title: '在库' });
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
assert.strictEqual(sessions.forgetMany([]), 0);
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
const remaining = sessions.list().map((row) => row.id).sort();
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
});
test('rebuildIndex 返回索引形状并与 list 一致', () => {
const { sessions } = fresh('rebuild');
const a = sessions.create({ entryId: 'e1', title: '甲' });
round(sessions, a.id, '问', '答');
const idx = sessions.rebuildIndex();
assert.strictEqual(idx.version, 1);
assert.strictEqual(idx.sessions.length, 1);
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
});
test('list 返回的是副本,改动不会污染内存索引', () => {
const { sessions } = fresh('clone');
const meta = sessions.create({ title: '原标题' });
const rows = sessions.list();
rows[0].title = '被改坏';
assert.strictEqual(sessions.list()[0].title, '原标题');
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
});
+338 -1
View File
@@ -326,7 +326,7 @@ test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
assert.doesNotMatch(body.messages[1].content, /data:image/);
});
test('超长上下文被截断且保留首尾', () => {
test('显式调用 clipContext 时中间挖空并保留首尾', () => {
const ai = setup();
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
const clipped = ai.clipContext(long, 2000);
@@ -336,6 +336,49 @@ test('超长上下文被截断且保留首尾', () => {
assert.ok(clipped.includes('省略'), '未标注截断');
});
test('正文完整外发,不再按 MAX_CHARS 静默截断', () => {
const ai = setup();
// 40 页文档实测:旧行为只发出 8 页,其余 32 页静默丢失,而界面仍显示全文字数
const body = 'X'.repeat(ai.MAX_CHARS * 4) + 'TAIL_MARK';
const msgs = ai.buildMessages('ask', body, '这讲了什么');
assert.ok(msgs[1].content.includes(body), '正文被截断了');
assert.ok(!msgs[1].content.includes('中间省略'), '不应再自动挖空正文');
assert.ok(msgs[1].content.includes('TAIL_MARK'));
});
test('接口报的上下文超限被转成可操作的中文提示', async () => {
const ai = setup();
for (const [status, raw] of [
[400, "This model's maximum context length is 8192 tokens, however you requested 90000 tokens"],
[400, 'prompt is too long: 250000 tokens > 200000 maximum'],
[413, 'Payload Too Large']
]) {
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: raw } }), { status }));
await assert.rejects(
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
(err) => {
assert.match(err.message, /上下文超出模型窗口/);
assert.match(err.message, /范围改小|更大窗口/);
return true;
},
`HTTP ${status} 未被识别为上下文超限`
);
}
// 普通错误不应被误判成超限
h.setHandler(() => streamResponse(
JSON.stringify({ error: { message: 'invalid temperature value' } }),
{ status: 400 }
));
await assert.rejects(
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
(err) => {
assert.doesNotMatch(err.message, /上下文超出模型窗口/);
return true;
}
);
});
test('不支持的任务类型被拒绝', () => {
const ai = setup();
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
@@ -359,3 +402,297 @@ test('取消请求时抛出 AbortError 而不是静默返回', async () => {
(e) => e.name === 'AbortError'
);
});
// 多轮对话历史
const PROTOCOL_STREAMS = {
'chat-completions': () => streamResponse(sseBody(['答'])),
anthropic: () => streamResponse(
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '答' } })}\n\n`
+ `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`
),
'openai-responses': () => streamResponse(
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: '答' })}\n\n`
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
)
};
async function captureBody(protocol, args, options) {
const ai = setup({ protocol, ...options });
let body = null;
h.setHandler((_url, opts) => {
body = JSON.parse(opts.body);
return PROTOCOL_STREAMS[protocol]();
});
await ai.stream(args);
return body;
}
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
// 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染
const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题';
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
if (protocol === 'chat-completions') {
assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user');
assert.strictEqual(base.messages[1].content, expectedUser);
} else if (protocol === 'anthropic') {
assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user');
assert.strictEqual(base.messages[0].content, expectedUser);
} else {
assert.strictEqual(base.input.length, 1, '单轮只该有一条 user');
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]);
}
for (const history of [undefined, null, [], 'not-an-array', {}]) {
const withArg = await captureBody(
protocol,
{ task: 'ask', text: '正文', question: '问题', history }
);
assert.strictEqual(
JSON.stringify(withArg),
JSON.stringify(base),
`${protocol} 的空 history 改变了请求体(history=${JSON.stringify(history)}`
);
}
}
});
test('chat-completions 把历史插在 system 之后、当前轮之前', async () => {
const body = await captureBody('chat-completions', {
task: 'ask',
text: '正文',
question: '第三个问题',
history: [
{ id: 'a', role: 'user', text: '第一个问题' },
{ id: 'b', role: 'assistant', text: '第一个回答' },
{ id: 'c', role: 'user', text: '第二个问题' },
{ id: 'd', role: 'assistant', text: '第二个回答' }
]
});
assert.deepStrictEqual(
body.messages.map((m) => m.role),
['system', 'user', 'assistant', 'user', 'assistant', 'user'],
'历史必须在 messages 里,且当前轮排最后'
);
assert.strictEqual(body.messages[1].content, '第一个问题');
assert.strictEqual(body.messages[2].content, '第一个回答');
assert.strictEqual(body.messages[3].content, '第二个问题');
assert.strictEqual(body.messages[4].content, '第二个回答');
assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失');
assert.match(body.messages[5].content, /正文/, '当前轮正文丢失');
assert.ok(
!/第一个问题/.test(body.messages[0].content),
'历史不该被塞进 system,模型会把它当成指令'
);
});
test('anthropic 历史进 messagessystem 仍在顶层', async () => {
const body = await captureBody('anthropic', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'user', text: '旧问题' },
{ id: 'b', role: 'assistant', text: '旧回答' }
]
});
assert.strictEqual(typeof body.system, 'string');
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '旧问题');
assert.strictEqual(body.messages[1].content, '旧回答');
assert.match(body.messages[2].content, /新问题/);
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
});
test('openai-responses 历史进 inputinstructions 与 store:false 保持', async () => {
const body = await captureBody('openai-responses', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'user', text: '旧问题' },
{ id: 'b', role: 'assistant', text: '旧回答' }
]
});
assert.strictEqual(typeof body.instructions, 'string');
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
assert.strictEqual(body.input[0].content, '旧问题');
assert.strictEqual(body.input[1].content, '旧回答');
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
assert.strictEqual(body.input[2].content[0].type, 'input_text');
assert.match(body.input[2].content[0].text, /新问题/);
});
test('历史里的图像不被重发,只发当前轮的图', async () => {
const image = visualContext().image;
const history = [
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
{ id: 'b', role: 'assistant', text: '看到了' }
];
const args = {
task: 'ask',
text: '',
question: '这张呢',
visualContexts: [visualContext()],
history
};
const chat = await captureBody('chat-completions', args, { vision: true });
const chatImages = chat.messages.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
);
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
const anthropic = await captureBody('anthropic', args, { vision: true });
const anthropicImages = anthropic.messages.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
);
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
const responses = await captureBody('openai-responses', args, { vision: true });
const responseImages = responses.input.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
);
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
const historyJson = JSON.stringify(history);
assert.strictEqual(historyJson, JSON.stringify([
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
{ id: 'b', role: 'assistant', text: '看到了' }
]), '不该原地改写调用方传进来的历史数组');
});
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
const body = await captureBody('anthropic', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
{ id: 'b', role: 'user', text: '真正的第一问' },
{ id: 'c', role: 'assistant', text: '第一答' }
]
});
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '真正的第一问');
});
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '上一问' },
{ id: 'b', role: 'assistant', text: '上一答' },
{ id: 'c', role: 'user', text: '没等到回答的追问' }
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
}
const flat = JSON.stringify(items);
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
}
});
test('历史内部相邻同角色被合并而不是丢弃', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '连问一' },
{ id: 'b', role: 'user', text: '连问二' },
{ id: 'c', role: 'assistant', text: '连答一' },
{ id: 'd', role: 'assistant', text: '连答二' }
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(
roles,
['user', 'assistant', 'user'],
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
);
const flat = JSON.stringify(items);
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
}
}
});
test('历史含空文本时不产生空 content', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '有效提问' },
{ id: 'b', role: 'assistant', text: ' ' },
{ id: 'c', role: 'assistant', text: '' },
{ id: 'd', role: 'user', text: null },
{ id: 'e', role: 'assistant', text: '有效回答' },
null
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
for (const item of items) {
const text = typeof item.content === 'string'
? item.content
: JSON.stringify(item.content);
assert.ok(text && text.trim(), `${protocol} 出现空 contentAnthropic 不接受空字符串`);
}
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
}
});
test('历史按旧到新排列,当前轮在最后', async () => {
const history = [];
for (let i = 1; i <= 3; i++) {
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
}
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '问题4',
history
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
.map((mark) => flat.findIndex((s) => s.includes(mark)));
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
for (let i = 1; i < order.length; i++) {
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
}
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
}
});
test('buildMessages 也接受历史参数', () => {
const ai = setup();
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
{ role: 'user', text: '旧问题' },
{ role: 'assistant', text: '旧回答' }
]);
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
assert.strictEqual(msgs[1].content, '旧问题');
assert.match(msgs[3].content, /新问题/);
});
+88
View File
@@ -151,6 +151,94 @@ test('主文件损坏时优先从原子写入备份恢复', () => {
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
});
test('批注计数统计对象总数,随内容变化失效', () => {
const { store, dir } = fresh();
const k = key('a.pdf');
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }, { type: 'Path' }] });
store.setPage('book', k, 4, { objects: [{ type: 'IText' }] });
// 是对象总数而非批注页数
assert.deepStrictEqual(store.getCounts(), { book: 3 });
// mtime + size 缓存必须在内容变化后失效
store.setPage('book', k, 4, { objects: [] });
assert.deepStrictEqual(store.getCounts(), { book: 2 });
store.setPage('book', k, 1, { objects: [] });
assert.deepStrictEqual(store.getCounts(), {});
// 同一条目的不同文档版本累加
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }] });
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'Rect' }] });
assert.deepStrictEqual(store.getCounts(), { book: 2 });
// 损坏文件计 0 而不是抛错,否则整个书库都拿不到计数
fs.writeFileSync(path.join(dir, 'reader-annotations', 'broken.json'), '{ bad');
assert.deepStrictEqual(store.getCounts(), { book: 2 });
store.forget('book');
assert.deepStrictEqual(store.getCounts(), {});
});
test('删除后重建同名批注文件不会命中旧缓存', () => {
const { store, dir } = fresh();
const folder = path.join(dir, 'reader-annotations');
const file = path.join(folder, 'book.json');
const k = key('a.pdf');
store.setPage('book', k, 1, { objects: [{ t: 'aa' }, { t: 'bb' }] });
// 固定到整毫秒,否则 utimesSync 无法精确复现原 mtime
const pinned = new Date(1700000000000);
fs.utimesSync(file, pinned, pinned);
const stale = fs.statSync(file);
assert.deepStrictEqual(store.getCounts(), { book: 2 });
// 构造与旧文件同尺寸、同 mtime 但内容不同的文件:
// 缓存键只有 mtime+sizeforget 不清缓存就会返回过期的 2
store.forget('book');
const rebuilt = JSON.parse(JSON.stringify({
version: 1,
entryId: 'book',
documents: { [k]: { pages: { 1: { objects: [{ t: 'aa' }], updatedAt: 0 } }, updatedAt: 0 } }
}));
let text = JSON.stringify(rebuilt, null, 2);
assert.ok(text.length < stale.size, '重建内容应短于原文件才能补齐到同尺寸');
rebuilt.documents[k].pages['1'].objects[0].t = 'aa'.padEnd(2 + (stale.size - text.length), 'z');
text = JSON.stringify(rebuilt, null, 2);
assert.strictEqual(Buffer.byteLength(text, 'utf8'), stale.size, '需精确构造同尺寸文件');
fs.mkdirSync(folder, { recursive: true });
fs.writeFileSync(file, text, 'utf8');
fs.utimesSync(file, pinned, pinned);
assert.strictEqual(fs.statSync(file).mtimeMs, stale.mtimeMs, '需精确复现同 mtime');
assert.strictEqual(fs.statSync(file).size, stale.size, '需精确复现同尺寸');
assert.deepStrictEqual(store.getCounts(), { book: 1 });
});
test('孤立批注按体积报告并可批量回收', () => {
const { store, dir } = fresh();
const k = key('a.pdf');
store.setPage('kept', k, 1, { objects: [{ t: 'Rect' }] });
store.setPage('gone_a', k, 1, { objects: [{ t: 'Rect' }, { t: 'Path' }] });
store.setPage('gone_b', k, 1, { objects: [{ t: 'IText' }] });
const orphans = store.orphanReport(['kept']);
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
assert.ok(orphans.every((o) => o.bytes > 0), '需报告体积供用户判断');
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').count, 2);
// 仍在书库的条目绝不能出现在回收清单里
assert.ok(!orphans.some((o) => o.entryId === 'kept'));
assert.strictEqual(store.forgetMany(orphans.map((o) => o.entryId)), 2);
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'gone_a.json')), false);
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'kept.json')), true);
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
assert.deepStrictEqual(store.orphanReport(['kept']), []);
// 传入非法 ID 不应中断其余回收
store.setPage('gone_c', k, 1, { objects: [{ t: 'Rect' }] });
assert.strictEqual(store.forgetMany(['../escape', 'gone_c']), 1);
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
});
test('forget 删除条目批注及备份残留', () => {
const { store, dir } = fresh();
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const atomic = require('../atomic-file');
const dirs = [];
function fresh(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-atomic-${tag}-`));
dirs.push(dir);
return dir;
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('元数据写入在改名前 fsync 数据', () => {
const dir = fresh('fsync');
const dest = path.join(dir, 'meta.json');
const order = [];
const realFsync = fs.fsyncSync;
const realRename = fs.renameSync;
fs.fsyncSync = function tracked(fd) {
order.push('fsync');
return realFsync.call(this, fd);
};
fs.renameSync = function tracked(from, to) {
order.push(`rename:${path.basename(String(from))}->${path.basename(String(to))}`);
return realRename.call(this, from, to);
};
try {
atomic.writeJson(dest, { hello: '世界' });
} finally {
fs.fsyncSync = realFsync;
fs.renameSync = realRename;
}
// rename 只保证目录项替换原子,不保证数据已落盘;
// 因此临时文件的 fsync 必须发生在改名之前
const firstRename = order.findIndex((step) => step.startsWith('rename:'));
assert.ok(firstRename > 0, `改名前应先 fsync,实际顺序 ${order.join(',')}`);
assert.strictEqual(order[0], 'fsync');
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { hello: '世界' });
});
test('写入失败时清理临时文件并把备份换回原位', () => {
const dir = fresh('rollback');
const dest = path.join(dir, 'meta.json');
atomic.writeJson(dest, { round: 1 });
const before = fs.readFileSync(dest, 'utf8');
const realRename = fs.renameSync;
let failed = false;
fs.renameSync = function failing(from, to) {
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
failed = true;
throw new Error('模拟替换失败');
}
return realRename.apply(this, arguments);
};
try {
assert.throws(() => atomic.writeJson(dest, { round: 2 }), /模拟替换失败/);
} finally {
fs.renameSync = realRename;
}
assert.ok(failed);
assert.strictEqual(fs.readFileSync(dest, 'utf8'), before);
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
});
test('目录 fsync 失败不影响写入结果', () => {
const dir = fresh('dirfail');
const dest = path.join(dir, 'meta.json');
const realOpen = fs.openSync;
// Windows 无法对目录取句柄,这条路径必须容错
fs.openSync = function guarded(target, flags, ...rest) {
if (String(target) === dir) throw new Error('EISDIR');
return realOpen.call(this, target, flags, ...rest);
};
try {
atomic.writeJson(dest, { ok: true });
} finally {
fs.openSync = realOpen;
}
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
});
+75 -6
View File
@@ -168,8 +168,8 @@ app.whenReady().then(async () => {
"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('确认框说明全文完整发送且超限由接口报错',
/完整发送/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
chk('确认框使用应用按钮而非原生弹窗',
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
@@ -193,7 +193,12 @@ app.whenReady().then(async () => {
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]));
// 界面告知的字数必须等于真正离开进程的字数。旧行为在这里砍掉 80% 正文却仍显示全文字数
chk('外发字数与界面告知一致,正文未被静默截断',
charsOf(received[0]) >= docChars,
`外发=${charsOf(received[0])} 界面告知=${docChars}`);
chk('外发正文不含本地截断标记',
!JSON.stringify(received[0]).includes('中间省略'));
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
const output = document.getElementById('aiOutput');
return output.querySelector('h1')?.textContent === '回答'
@@ -245,7 +250,8 @@ app.whenReady().then(async () => {
})()`));
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 pageContent = received[1] && received[1].messages && received[1].messages.at(-1).content;
const pageImage = Array.isArray(pageContent)
? pageContent.find((part) => part && part.type === 'image_url')
: null;
@@ -328,13 +334,72 @@ app.whenReady().then(async () => {
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 regionContent = received[2] && received[2].messages && received[2].messages.at(-1).content;
const regionImage = Array.isArray(regionContent)
? regionContent.find((part) => part && part.type === 'image_url')
: null;
chk('框选区域作为单张图像上下文发送', received.length === 3
&& /^data:image\/jpeg;base64,/.test(regionImage?.image_url?.url || ''));
// 同一会话的第三轮:前两轮必须作为历史外发,且历史里不能夹带图像
const regionMessages = (received[2] && received[2].messages) || [];
chk('多轮对话把前几轮问答作为历史发送',
regionMessages.length >= 4
&& regionMessages[0].role === 'system'
&& regionMessages.at(-1).role === 'user'
&& regionMessages.slice(1, -1).some((m) => m.role === 'assistant'),
regionMessages.map((m) => m.role).join(','));
chk('历史消息只带文本,不重复上传图像',
regionMessages.slice(0, -1).every((m) => typeof m.content === 'string'),
regionMessages.map((m) => (typeof m.content === 'string' ? 'str' : 'arr')).join(','));
// 会话必须落盘:关掉窗口再开回来,历史消息与会话列表都应原样恢复
const diskSessions = require(path.join(ROOT, 'src', 'reader', 'ai-sessions'));
const storedList = diskSessions.list({ entryId: e.id });
const storedId = storedList[0] && storedList[0].id;
const storedMessages = storedId ? diskSessions.messages(storedId, { limit: 100 }).messages : [];
chk('多轮问答持久化到磁盘会话',
storedList.length === 1 && storedMessages.length === 6
&& storedMessages.filter((m) => m.role === 'user').length === 3
&& storedMessages.filter((m) => m.role === 'assistant').length === 3,
`会话=${storedList.length} 消息=${storedMessages.length}`);
// 存的是用户看见的那句提问,不是整篇正文,否则重开后气泡会变成十几万字原文
chk('会话只存提问本身,正文以 contextRef 摘要记录',
storedMessages[0].role === 'user'
&& storedMessages[0].text === '这章讲了什么'
&& storedMessages[0].contextRef?.scope === 'document'
&& storedMessages[0].contextRef.chars >= docChars
&& storedMessages[0].contextRef.hash.length === 32,
`${storedMessages[0].text.slice(0, 20)} / ${storedMessages[0].contextRef?.chars}`);
chk('历史图像以 imageId 引用而非内联 base64',
storedMessages.filter((m) => m.images.length).every((m) => m.images.every(
(img) => /^img_[0-9a-f]{64}$/.test(img.imageId) && img.base64 === undefined
)),
JSON.stringify(storedMessages.map((m) => m.images.map((i) => i.imageId.slice(0, 12)))));
const reopened = new BrowserWindow({
show: false, width: 1200, height: 860,
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
});
await reopened.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
await new Promise((r) => setTimeout(r, 9000));
const reopenedJs = (code) => reopened.webContents.executeJavaScript(code);
await reopenedJs("document.querySelector('[data-pane=\"ai\"]').click()");
await new Promise((r) => setTimeout(r, 2000));
const restored = await reopenedJs(`(() => ({
bubbles: document.querySelectorAll('#aiOutput .ai-msg').length,
first: (document.querySelector('#aiOutput .ai-msg') || {}).textContent || '',
options: document.getElementById('aiSessionSelect').options.length
}))()`);
chk('重开阅读器恢复会话与历史气泡',
restored.bubbles === 6 && restored.options === 1 && restored.first.includes('这章讲了什么'),
`气泡=${restored.bubbles} 会话=${restored.options}`);
const requestsBeforeReopen = received.length;
await new Promise((r) => setTimeout(r, 1500));
chk('恢复历史不会重新调用模型', received.length === requestsBeforeReopen,
`${requestsBeforeReopen} -> ${received.length}`);
reopened.destroy();
chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
const output = document.getElementById('aiOutput');
const text = 'x'.repeat(256 * 1024 + 1);
@@ -433,4 +498,8 @@ app.whenReady().then(async () => {
console.log(`\n通过 ${results.length - bad}/${results.length}`);
server.close();
app.exit(bad ? 1 : 0);
}).catch((e) => { console.error('异常:', e); app.exit(1); });
}).catch((e) => {
console.error('异常:', e);
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
app.exit(1);
});
@@ -411,6 +411,46 @@ app.whenReady().then(async () => {
check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
check('重开窗口无渲染错误', second.errors.length === 0, second.errors.slice(0, 2).join(' | '));
// 书库卡片上的批注计数必须来自真实落盘的批注,而不是渲染层自己数的
const storedCount = annotations.getCounts()[String(entry.id)] || 0;
check('批注已落盘并可计数', storedCount > 0, `count=${storedCount}`);
const libraryWindow = BrowserWindow.getAllWindows()
.find((w) => !w.isDestroyed() && /index\.html/.test(w.webContents.getURL()));
check('存在书库窗口', !!libraryWindow);
if (libraryWindow) {
libraryWindow.show();
await js(libraryWindow, `(async () => {
document.querySelector('[data-tab="library"]').click();
await new Promise((r) => setTimeout(r, 400));
document.querySelector('[data-tab="notes"]').click();
await new Promise((r) => setTimeout(r, 300));
document.querySelector('[data-tab="library"]').click();
await new Promise((r) => setTimeout(r, 1800));
})()`);
const badge = await js(libraryWindow, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
if (!card) return { missing: true };
const cover = card.querySelector('.card-cover').getBoundingClientRect();
const annot = card.querySelector('.card-badge.annotation-count');
const end = card.querySelector('.card-cover-badges.end');
const start = card.querySelector('.card-cover-badges.start');
if (!annot) return { noBadge: true };
const ar = annot.getBoundingClientRect();
const sr = start.getBoundingClientRect();
return {
text: annot.textContent.trim(),
inEnd: end.contains(annot),
insideCover: ar.right <= cover.right + 0.5 && ar.bottom <= cover.bottom + 0.5,
rightOfStatus: ar.left >= sr.right - 0.5,
statusText: start.textContent.trim()
};
})()`);
check('书库卡片显示与落盘一致的批注数',
badge.text === `批注 ${storedCount}`, JSON.stringify(badge));
check('批注标识在封面右下角,不与左下角状态重叠',
badge.inEnd && badge.insideCover && badge.rightOfStatus, JSON.stringify(badge));
}
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
+312 -7
View File
@@ -59,17 +59,20 @@ async function js(source) {
return win.webContents.executeJavaScript(source);
}
async function poll(name, predicate, timeout = 8000) {
// soft=true 时超时不抛也不记失败,只返回 false,交给调用方自己断言,
// 这样失败信息里能带上真实量到的状态而不是一句"等待超时"
async function poll(name, predicate, timeout = 8000, soft = false) {
const deadline = Date.now() + timeout;
let lastError = null;
while (Date.now() < deadline) {
try {
if (await predicate()) return;
if (await predicate()) return true;
} catch (error) {
lastError = error;
}
await wait(50);
}
if (soft) return false;
const detail = lastError ? lastError.message : '等待超时';
check(name, false, detail);
throw new Error(`${name}: ${detail}`);
@@ -159,6 +162,7 @@ app.whenReady().then(async () => {
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 noteWindow = require(path.join(ROOT, 'src', 'reader', 'note-window'));
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'));
@@ -481,13 +485,28 @@ app.whenReady().then(async () => {
)) || null;
return !!readerWindow;
});
await poll('封面打开的 PDF 在内置阅读器渲染', async () => (
// 这本书的文件顺序是 [txt, pdf],封面走的是"第一个可阅读文件",
// 也就是 txt(txt/md 同样能内置阅读,走 text-adapter 转 epub 渲染)。
// 这里断言"渲染出正文",不要写死 PDF 画布:那样等于把
// "txt 不可阅读所以退到 pdf" 这个旧缺陷当成期望行为锁死
await poll('封面打开的书在内置阅读器渲染出正文', async () => (
!readerWindow.isDestroyed()
&& readerWindow.webContents.executeJavaScript(
"document.querySelector('.pdfx-page[data-page=\"1\"] .pdfx-canvas')?.width > 0"
)
), 15000);
&& readerWindow.webContents.executeJavaScript(`(() => {
if (document.querySelector('.doc-overlay.err')) return false;
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (canvas && canvas.width > 0) return true;
const frame = document.querySelector('.host-epub iframe');
const body = frame && frame.contentDocument && frame.contentDocument.body;
return !!(body && body.textContent.trim().length > 0);
})()`)
), 20000);
check('点击可阅读图书封面直接打开内置阅读器', !!readerWindow);
check(
'封面打开的是第一个可阅读文件(txt 也算)',
(await readerWindow.webContents.executeJavaScript(
"new URLSearchParams(location.search).get('fileIndex')"
)) === '0'
);
readerWindow.destroy();
await wait(200);
check(
@@ -1589,6 +1608,292 @@ app.whenReady().then(async () => {
);
dialog.showOpenDialog = originalShowOpenDialog;
// --- 笔记独立窗口 ---
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
await pollJs('笔记页有可开窗的卡片', "document.querySelectorAll('#notesList .note-card').length > 0");
const windowNote = readerStore.listNotes({}).find((note) => note.associated !== false);
check('存在可用于开窗的笔记', !!windowNote);
// 只数笔记窗口。总窗口数会被阅读器窗口的开关干扰,
// 之前用总数当基线,阅读器中途关掉就把「没多开窗」误判成失败
const noteWindowCount = () => BrowserWindow.getAllWindows()
.filter((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'))
.length;
check('开窗前没有笔记窗口', noteWindowCount() === 0, `笔记窗口数=${noteWindowCount()}`);
// 按钮必须限定在目标笔记那张卡片内:笔记页此时有多张卡片,
// 全局找「编辑」会命中别的卡片,断言就变成了自欺欺人
const cardScript = (inner) => `(() => {
const target = document.querySelector('#notesList .note-card[data-note-id=' +
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
if (!target) throw new Error('找不到目标笔记卡片');
${inner}
})()`;
const cardLabels = () => js(cardScript(
'return [...target.querySelectorAll(".note-action")].map((b) => b.textContent).join(",");'
));
const clickCardAction = (label) => js(cardScript(`
const button = [...target.querySelectorAll(".note-action")]
.find((item) => item.textContent === ${JSON.stringify(label)});
if (!button) throw new Error("找不到按钮:" + ${JSON.stringify(label)});
button.click();
return true;
`));
await clickCardAction('独立窗口');
// 窗口创建与 URL 就位之间有间隔,刚建好时 getURL() 还是空串,必须轮询
const findNoteWindow = () => BrowserWindow.getAllWindows()
.find((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'));
await poll(
'点击独立窗口后真的多出一个笔记窗口',
async () => noteWindowCount() === 1 && !!findNoteWindow(),
15000
);
const noteWin = findNoteWindow();
check('新窗口加载的是笔记页面', !!noteWin);
const noteJs = (source) => noteWin.webContents.executeJavaScript(source);
// 多标签后表单是每个标签一份,查询必须限定在当前激活的那个视图内,
// 否则回收/切换过程中会量到别的标签
const activeScript = (inner) => `(() => {
const view = [...document.querySelectorAll('.note-tab-view')]
.find((item) => !item.classList.contains('inactive'));
if (!view) throw new Error('没有激活的笔记标签');
${inner}
})()`;
await poll(
'笔记窗口标签就绪',
() => noteJs(`(() => {
const view = [...document.querySelectorAll('.note-tab-view')]
.find((item) => !item.classList.contains('inactive'));
return !!(view && view.querySelector('.note-window-title'));
})()`),
15000
);
check(
'笔记窗口载入的是被点开的那一条',
(await noteJs(activeScript("return view.querySelector('.note-window-title').value;")))
=== String(windowNote.title || '')
&& (await noteJs("document.getElementById('noteWindowError').textContent")) === '',
await noteJs(activeScript("return view.querySelector('.note-window-title').value;"))
);
check(
'开出的是一个标签',
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
);
// 同一条笔记不允许开出第二个标签,否则两个编辑器会整条覆盖对方
await clickCardAction('切到窗口');
await wait(1200);
check(
'同一条笔记再次开窗只聚焦不新增窗口',
noteWindowCount() === 1,
`笔记窗口数=${noteWindowCount()}`
);
check(
'同一条笔记再次开窗也不新增标签',
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
);
// 已开窗时该卡片的按钮改为切窗,避免模态与窗口同时编辑同一条
const openedLabels = await cardLabels();
check(
'已开窗后该笔记不再提供开模态的编辑按钮',
openedLabels.includes('在窗口中编辑') && !openedLabels.split(',').includes('编辑'),
openedLabels
);
await clickCardAction('在窗口中编辑');
await wait(1000);
check(
'点「在窗口中编辑」不会打开模态',
await js("document.getElementById('modal').classList.contains('hidden')")
);
// 断言真正落盘的内容,而不是界面状态
const editedTitle = `窗口改名 ${Date.now()}`;
await noteJs(activeScript(`
const title = view.querySelector('.note-window-title');
title.value = ${JSON.stringify(editedTitle)};
title.dispatchEvent(new Event('input', { bubbles: true }));
const tags = view.querySelector('.note-window-tags-input');
tags.value = '窗口标签';
tags.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`));
// 有未保存修改时标签上要有脏标记,否则关闭前的二次确认无从触发
await poll(
'未保存的修改在标签上有脏标记',
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 1"),
8000
);
check('未保存的修改在标签上有脏标记', true);
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
await poll(
'笔记窗口的修改真正落盘',
async () => {
const stored = readerStore.listNotes({}).find((note) => note.id === windowNote.id);
return !!stored && stored.title === editedTitle && stored.tags.includes('窗口标签');
},
12000
);
check('笔记窗口保存后落盘内容正确', true);
await poll(
'笔记窗口的改动回流到主窗口列表',
() => js(`Array.from(document.querySelectorAll('#notesList .note-title'))
.some((node) => node.textContent.trim() === ${JSON.stringify(editedTitle)})`),
12000
);
check('主窗口列表随笔记窗口保存刷新', true);
await poll(
'保存后脏标记清除',
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 0"),
8000
);
check('保存后脏标记清除', true);
const noteWinErrors = [];
noteWin.webContents.on('console-message', (event) => {
if (event.level >= 2) noteWinErrors.push(event.message.slice(0, 120));
});
await wait(200);
check('笔记窗口没有控制台错误', noteWinErrors.length === 0, noteWinErrors.slice(0, 2).join(' | '));
// 第二条笔记要进同一个窗口的新标签,而不是再开一个窗口
const secondNote = readerStore.listNotes({})
.find((note) => note.id !== windowNote.id && note.associated !== false);
if (secondNote) {
await js(`(() => {
const target = document.querySelector('#notesList .note-card[data-note-id=' +
JSON.stringify(${JSON.stringify(String(secondNote.id))}) + ']');
if (!target) throw new Error('找不到第二条笔记卡片');
const button = [...target.querySelectorAll('.note-action')]
.find((item) => item.textContent === '独立窗口');
if (!button) throw new Error('第二条笔记没有独立窗口按钮');
button.click();
return true;
})()`);
await poll(
'第二条笔记进入同一窗口的新标签',
async () => noteWindowCount() === 1
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 2,
15000
);
check(
'第二条笔记进入同一窗口的新标签',
noteWindowCount() === 1,
`笔记窗口数=${noteWindowCount()}`
);
check(
'只有一个标签视图可见',
(await noteJs(
"[...document.querySelectorAll('.note-tab-view')].filter((v) => !v.classList.contains('inactive')).length"
)) === 1
);
// 刚打开且只在编辑区点选、按方向键,不改内容,不能被判定为已修改。
// 早前用 pointerdown/keydown 判脏时这里必然误报,一切标签都要求二次确认;
// 画布还会因为 version 1→2 归一化在挂载时就"变更"一次
await wait(1200);
await noteJs(activeScript(`
const host = view.querySelector('.note-window-editor');
host.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
host.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
host.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }));
host.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
return true;
`));
await wait(900);
check(
'只点选不改内容不会被误判为已修改',
(await noteJs("document.querySelectorAll('.doctab-dirty').length")) === 0,
`脏标记数=${await noteJs("document.querySelectorAll('.doctab-dirty').length")}`
);
}
// 关窗前的未保存拦截:取消之后必须还能再次触发确认。
// 主进程 closePending 不复位时第二次点关闭会被静默忽略,
// 而看门狗十秒后仍会把带未保存内容的窗口销毁。
// 此时激活的是第二条笔记的标签,必须先切回已保存过的那条,
// 否则下面"改回原样"比对的是另一条笔记的基线
await noteJs(`(() => {
const tab = document.querySelector('.doctab[data-note-id=' +
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
if (!tab) throw new Error('找不到目标标签');
tab.click();
return true;
})()`);
await poll(
'切回已保存的那个标签',
() => noteJs(activeScript(
`return view.dataset.noteId === ${JSON.stringify(String(windowNote.id))};`
)),
10000
);
await noteJs(activeScript(`
const title = view.querySelector('.note-window-title');
title.value = '关窗前的未保存修改';
title.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`));
await poll(
'关窗前已置脏',
() => noteJs("document.querySelectorAll('.doctab-dirty').length >= 1"),
8000
);
await noteJs("document.getElementById('closeBtn').click()");
await poll(
'关窗被未保存确认拦下',
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
10000
);
check('关窗被未保存确认拦下,窗口还在', !noteWin.isDestroyed());
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
await wait(1000);
check('取消后窗口保留', !noteWin.isDestroyed());
await noteJs("document.getElementById('closeBtn').click()");
await poll(
'取消之后再次关窗仍会弹确认',
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
10000
);
check('取消之后再次关窗仍会弹确认', !noteWin.isDestroyed());
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
await wait(800);
// 存盘收尾,避免未保存状态干扰后面的删除断言
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
await poll(
'取消关闭后仍能正常保存',
() => noteJs(`(() => {
const tab = document.querySelector('.doctab[data-note-id=' +
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
return !!tab && !tab.querySelector('.doctab-dirty');
})()`),
10000
);
check('取消关闭后仍能正常保存', true);
// 笔记被删除后只关掉它那个标签,窗口和别的标签要留着
await js(`window.api.reader.removeNote(${JSON.stringify(windowNote.entryId)}, ${JSON.stringify(windowNote.id)})`);
if (secondNote) {
await poll(
'删除笔记只关掉对应标签',
async () => !noteWin.isDestroyed()
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 1,
12000
);
check('删除笔记只关掉对应标签,窗口留着', !noteWin.isDestroyed());
// 删掉最后一个标签,窗口才该退场
const remainingId = await noteJs("document.querySelector('.doctab').dataset.noteId");
const remaining = readerStore.listNotes({}).find((note) => note.id === remainingId);
check('剩下的标签是另一条笔记', !!remaining && remaining.id !== windowNote.id, String(remainingId));
if (remaining) {
await js(`window.api.reader.removeNote(${JSON.stringify(remaining.entryId)}, ${JSON.stringify(remaining.id)})`);
}
}
await poll('最后一个标签消失后窗口自动关闭', async () => noteWin.isDestroyed(), 12000);
check('最后一个标签消失后窗口自动关闭', noteWin.isDestroyed());
check('窗口关闭后主进程标签集清空', noteWindow.openIds().length === 0, JSON.stringify(noteWindow.openIds()));
await wait(300);
check(
'主渲染进程没有控制台错误',
@@ -11,6 +11,8 @@ 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');
const TXT_FILE = path.join(TMP, 'reader-features.txt');
const MD_FILE = path.join(TMP, 'reader-features.md');
app.setPath('userData', TMP);
app.setPath('appData', TMP);
@@ -194,6 +196,58 @@ function makeMobi(file) {
fs.writeFileSync(file, output);
}
function makeTxt(file) {
const lines = ['纯文本夹具标题', ''];
for (let i = 1; i <= 3; i++) {
lines.push(`${i}章 编码与分章`, '');
for (let k = 0; k < 12; k++) {
lines.push(`${i}章第${k + 1}段:TXT-MARK-${i}-${k} 中文正文用于校验解码与全文提取。`);
}
lines.push('');
}
const text = lines.join('\r\n');
// 带 BOM 的 UTF-16LE 是 Windows 记事本另存的默认之一,编码探测必须在真实浏览器里也成立
const body = Buffer.from(text, 'utf16le');
fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
return text;
}
function makeMd(file) {
const source = [
'# Markdown 夹具',
'',
'正文段落包含 **加粗** 与 `行内代码`,用于确认渲染而不是纯文本显示。',
'',
'## 危险内容小节',
'',
'<script>window.__mdScriptExecuted = true</script>',
'',
'<img src="x" onerror="window.__mdHandlerExecuted = true">',
'',
'[不安全链接](javascript:window.__mdLinkExecuted=true)',
'',
'<iframe src="https://untrusted.example/frame"></iframe>',
'',
'## 结构小节',
'',
'- 列表项一',
'- 列表项二',
'',
'```js',
'const fenced = "code block";',
'```',
'',
'| 列一 | 列二 |',
'|---|---|',
'| 单元格 | MD-TABLE-CELL |',
'',
'> 引用块 MD-QUOTE-MARK',
''
].join('\n');
fs.writeFileSync(file, source, 'utf8');
return source;
}
async function js(win, source) {
try {
return await win.webContents.executeJavaScript(source);
@@ -515,6 +569,8 @@ app.whenReady().then(async () => {
fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
makeTxt(TXT_FILE);
makeMd(MD_FILE);
const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
drmFixture.writeUInt16BE(1, 96 + 12);
fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
@@ -549,7 +605,9 @@ app.whenReady().then(async () => {
{ 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' }
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' },
{ path: TXT_FILE, name: 'reader-features.txt', format: 'TXT' },
{ path: MD_FILE, name: 'reader-features.md', format: 'MD' }
]
});
await wait(1200);
@@ -671,6 +729,95 @@ app.whenReady().then(async () => {
pdfWin.setSize(1280, 900);
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
.classList.contains('pdfx-layout-single')`);
// 前面的分页导航把视口停在第 3 页,离屏页会被回收成空白占位,
// 必须先回到第 1 页再测画质,否则量到的是占位画布而不是真实渲染结果
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '0';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 1 页'`);
const canvasProbe = `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
const css = canvas.getBoundingClientRect().width;
return {
dpr: window.devicePixelRatio,
backing: canvas.width,
css: Math.round(css),
ratio: canvas.width / css
};
})()`;
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
return canvas && canvas.width > 0;
})()`);
check('标准画质下第 1 页有真实墨迹',
imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`)));
const qualityBase = await js(pdfWin, canvasProbe);
// 生效倍率是 max(dpr, quality)HiDPI 屏上不叠乘,所以不能断言"backing 翻倍"
check('默认画质下 backing 比例等于设备像素比',
Math.abs(qualityBase.ratio - qualityBase.dpr) < 0.05,
`dpr=${qualityBase.dpr} ratio=${qualityBase.ratio.toFixed(3)}`);
const targetQuality = Math.min(3, Math.ceil(qualityBase.dpr + 1));
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '${targetQuality}';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${targetQuality}) < 0.05;
})()`);
const qualityHigh = await js(pdfWin, canvasProbe);
// 超采样只能放大 backing store,CSS 盒子必须原地不动,否则是把页面放大而不是提高画质
check('PDF 画质档提高 backing 分辨率且不改变版面尺寸',
Math.abs(qualityHigh.ratio - targetQuality) < 0.05
&& qualityHigh.backing > qualityBase.backing
&& Math.abs(qualityHigh.css - qualityBase.css) <= 1,
`ratio ${qualityBase.ratio.toFixed(3)}->${qualityHigh.ratio.toFixed(3)} `
+ `backing ${qualityBase.backing}->${qualityHigh.backing} css ${qualityBase.css}->${qualityHigh.css}`);
const qualityRatios = await js(pdfWin, `Array.from(document.querySelectorAll('.pdfx-canvas'))
.filter((canvas) => canvas.width > 0)
.map((canvas) => Math.round(canvas.width / canvas.getBoundingClientRect().width * 100) / 100)`);
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
check('画质切换后同屏各页倍率一致',
qualityRatios.length > 0 && new Set(qualityRatios).size === 1,
JSON.stringify(qualityRatios));
// 画布尺寸在重建时立即变大,墨迹要等这一页重绘完才落上去,只等比例会量到空白中间态
let highInk = false;
try {
await waitUntil(async () => {
highInk = imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`));
return highInk;
}, 15000, 250);
} catch (error) { /* 交给下面的断言报告 */ }
check('提高画质后仍渲染出真实墨迹而不是空白画布', highInk);
await waitUntil(() => Promise.resolve(
settings.get('reader.pdfRenderQuality', 0) === targetQuality
));
check('PDF 画质偏好已持久化', true);
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '1';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${qualityBase.dpr}) < 0.05;
})()`);
check('画质调回标准档后恢复设备像素比', true);
// 画质检查需要停在第 1 页,后续用例仍按第 3 页断言,这里把视口还回去
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '1000';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
try {
await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
.some((node) => node.firstChild && node.firstChild.data.trim())`);
@@ -1356,6 +1503,181 @@ app.whenReady().then(async () => {
drmReader.errors.slice(0, 3).join(' | '));
drmReader.win.close();
const txtReader = await openReader(entry.id, 5);
const txtWin = txtReader.win;
await waitForJs(txtWin, `!document.querySelector('.doc-overlay')
&& !!document.querySelector('.host-epub iframe')?.contentDocument?.body?.textContent.trim()`, 30000);
check('TXT 通过内置阅读器打开而不是回退到外部程序',
await js(txtWin, `!document.querySelector('.doc-overlay.err')
&& !!document.querySelector('.host-epub iframe')`));
const txtToc = await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('TXT 按章节标题切分并生成可跳转目录',
txtToc.length >= 3 && txtToc.some((label) => /第1章/.test(label)),
JSON.stringify(txtToc.slice(0, 5)));
await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /第1章/.test(button.textContent)).click()`);
await waitForJs(txtWin, `document.querySelector('.host-epub iframe').contentDocument.body
.textContent.includes('TXT-MARK-1-0')`, 20000);
const txtDecoded = await js(txtWin, `(() => {
const text = document.querySelector('.host-epub iframe').contentDocument.body.textContent;
return {
chinese: text.includes('第1章第1段'),
replacement: text.includes('\\ufffd'),
nul: text.includes('\\u0000')
};
})()`);
// 带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本变乱码且用户无从修正
check('带 BOM 的 UTF-16LE 纯文本正确解码为中文而不是乱码',
txtDecoded.chinese && !txtDecoded.replacement && !txtDecoded.nul,
JSON.stringify(txtDecoded));
// AI 的"全文"范围依赖 textOf(locator,'document'),缺章会让模型答非所问且用户无从察觉
const txtFullText = await js(txtWin, `(async () => {
const module = await import('./reader/text-adapter.mjs');
const adapter = module.createTextAdapter('txt');
try {
const bytes = await window.api.reader.bytes(${JSON.stringify(entry.id)}, 5);
await adapter.load(bytes.data, {});
const host = document.createElement('div');
document.body.appendChild(host);
await adapter.renderTo(host, null, { fontSize: 16, theme: 'light', lineHeight: 1.7 });
const full = await adapter.textOf(null, 'document');
const page = await adapter.textOf(null, 'page');
host.remove();
return {
marks: (full.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
pageMarks: (page.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
length: full.length
};
} finally {
adapter.destroy();
}
})()`);
check('TXT 全文范围覆盖所有章节而不是只有当前章',
txtFullText.marks === 36 && txtFullText.pageMarks < txtFullText.marks,
JSON.stringify(txtFullText));
check('TXT 阅读器没有控制台错误', txtReader.errors.length === 0,
txtReader.errors.slice(0, 3).join(' | '));
txtWin.close();
// 上面几条都是直接调 IPC 打开的,绕过了书库界面。
// 渲染层自己那份可阅读格式白名单漏掉 txt/md 时,IPC 照样能开,
// 但卡片上根本不会出现「阅读」按钮,用户看到的就是"内置阅读器打不开 txt"
{
const mainWin = BrowserWindow.getAllWindows()
.find((w) => !w.isDestroyed() && String(w.webContents.getURL()).includes('index.html'));
check('存在主窗口用于校验书库入口', !!mainWin);
if (mainWin) {
mainWin.show();
await js(mainWin, `document.querySelector('.tab[data-tab="library"]').click()`);
await waitForJs(mainWin,
`document.querySelectorAll('#libGrid > .card').length > 0`, 20000);
const cardEntry = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
coverActs: !!card.querySelector('.card-cover[data-act="read"]'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('书库卡片提供内置阅读入口', cardEntry.hasRead && cardEntry.coverReadable
&& cardEntry.coverActs, JSON.stringify(cardEntry));
// 只含 txt 的条目也要能读:白名单漏项时这条会失败
const txtOnly = library.add({
title: 'TXT Only Fixture',
files: [{ path: TXT_FILE, name: 'txt-only.txt', format: 'TXT' }]
});
await js(mainWin, `document.getElementById('rescanBtn').click()`);
await waitForJs(mainWin,
`!!document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]')`, 20000);
const txtCard = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('纯 TXT 条目在书库里也有阅读入口',
txtCard.hasRead && txtCard.coverReadable, JSON.stringify(txtCard));
library.remove(txtOnly.id);
mainWin.hide();
}
}
const mdReader = await openReader(entry.id, 6);
const mdWin = mdReader.win;
await waitForJs(mdWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
?.textContent.includes('Markdown 夹具')`, 30000);
const mdHeadStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h1: doc.querySelectorAll('h1').length,
strong: doc.querySelectorAll('strong').length,
code: doc.querySelectorAll('code').length
};
})()`);
check('Markdown 首节渲染成标题与行内标记而不是纯文本',
mdHeadStructure.h1 >= 1 && mdHeadStructure.strong >= 1 && mdHeadStructure.code >= 1,
JSON.stringify(mdHeadStructure));
const mdToc = await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('Markdown 按标题层级生成目录',
mdToc.length >= 3 && mdToc.some((label) => /结构小节/.test(label)),
JSON.stringify(mdToc));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /结构小节/.test(button.textContent)).click()`);
await waitForJs(mdWin, `document.querySelector('.host-epub iframe').contentDocument
.body.textContent.includes('MD-TABLE-CELL')`, 20000);
const mdStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h2: doc.querySelectorAll('h2').length,
li: doc.querySelectorAll('li').length,
pre: doc.querySelectorAll('pre').length,
table: doc.querySelectorAll('table td, table th').length,
quote: doc.querySelectorAll('blockquote').length
};
})()`);
check('Markdown 列表、代码块、表格与引用渲染成真实块级结构',
mdStructure.h2 >= 1 && mdStructure.li >= 2 && mdStructure.pre >= 1
&& mdStructure.table >= 1 && mdStructure.quote >= 1,
JSON.stringify(mdStructure));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /危险内容小节/.test(button.textContent)).click()`);
await wait(1200);
// 裸 HTML 被 markdown-it 转义成文本,所以只能按 DOM 断言,
// 用 innerHTML 匹配 "onerror" 会把转义后的字面量误判成漏网
const mdSafety = await js(mdWin, `(() => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
const nodes = Array.from(doc.querySelectorAll('*'));
return {
script: doc.querySelectorAll('script').length,
iframe: doc.querySelectorAll('iframe').length,
img: doc.querySelectorAll('img').length,
eventAttrs: nodes.filter((node) => Array.from(node.attributes || [])
.some((attr) => /^on/i.test(attr.name))).length,
unsafeHref: Array.from(doc.querySelectorAll('a[href]'))
.filter((a) => /^(javascript|vbscript|data|file):/i.test(a.getAttribute('href') || '')).length,
executedScript: !!(frame.contentWindow.__mdScriptExecuted || window.__mdScriptExecuted),
executedHandler: !!(frame.contentWindow.__mdHandlerExecuted || window.__mdHandlerExecuted),
executedLink: !!(frame.contentWindow.__mdLinkExecuted || window.__mdLinkExecuted)
};
})()`);
check('Markdown 中的脚本、事件属性与 javascript: 链接被净化且未执行',
mdSafety.script === 0 && mdSafety.iframe === 0 && mdSafety.img === 0
&& mdSafety.eventAttrs === 0 && mdSafety.unsafeHref === 0
&& !mdSafety.executedScript && !mdSafety.executedHandler && !mdSafety.executedLink,
JSON.stringify(mdSafety));
check('Markdown 阅读器没有控制台错误', mdReader.errors.length === 0,
mdReader.errors.slice(0, 3).join(' | '));
mdWin.close();
const readerFile = path.join(TMP, 'reader.json');
const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
check('readerStore 使用隔离目录中的 v6 存储',
+92
View File
@@ -243,6 +243,98 @@ test('failed tag writes roll back both catalog and item references', () => {
assert.ok(!fs.existsSync(`${file}.bak`));
});
test('批量更新只写一次索引,任一条目无效则整批回滚', () => {
const root = freshRoot('update-many');
const a = store.add({ title: '甲', tags: ['共有'] });
const b = store.add({ title: '乙', tags: ['共有', '仅乙'] });
const c = store.add({ title: '丙', tags: [] });
const file = indexPath(root);
// 逐条 update 会把整个索引重写 N 遍,批量必须收敛成一次。
// 索引经 fd 写入,因此统计 open 而不是 writeFileSync 的路径参数。
const countIndexWrites = (fn) => {
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
try { fn(); } finally { fs.openSync = originalOpen; }
return writes;
};
let updated = 0;
const writes = countIndexWrites(() => {
updated = store.updateMany([
{ id: a.id, patch: { tags: ['共有', '新增'] } },
{ id: b.id, patch: { shelfId: null, tags: ['共有'] } }
]).updated;
});
assert.strictEqual(updated, 2);
assert.strictEqual(writes, 1, '批量更新应只写一次索引');
// 对照:逐条 update 会写两次,证明上面的 1 不是计数器失灵
const loopWrites = countIndexWrites(() => {
store.update(a.id, { tags: ['共有', '新增'] });
store.update(b.id, { tags: ['共有'] });
});
assert.strictEqual(loopWrites, 2, '逐条更新应写两次,用于对照');
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
assert.deepStrictEqual(store.get(b.id).tags, ['共有']);
assert.deepStrictEqual(store.get(c.id).tags, [], '未列出的条目不应被改动');
const snapshot = fs.readFileSync(file, 'utf8');
assert.throws(() => store.updateMany([
{ id: a.id, patch: { tags: ['不该生效'] } },
{ id: 'missing-id', patch: { tags: ['x'] } }
]), /条目不存在/);
assert.strictEqual(fs.readFileSync(file, 'utf8'), snapshot, '整批回滚不应留下部分写入');
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
assert.throws(() => store.updateMany([{ patch: {} }]), /条目 ID/);
assert.deepStrictEqual(store.updateMany([]), { updated: 0 });
});
test('批量移除只写一次索引,可选删除库内文件', () => {
const root = freshRoot('remove-many');
fs.mkdirSync(path.join(root, 'files'), { recursive: true });
const made = [];
for (let i = 0; i < 3; i++) {
const abs = path.join(root, 'files', `book-${i}.pdf`);
fs.writeFileSync(abs, '%PDF-1.4\n');
made.push(store.add({ title: `${i}`, files: [{ path: abs, name: `book-${i}.pdf` }] }));
}
const outside = path.join(root, '..', `outside-${path.basename(root)}.pdf`);
fs.writeFileSync(outside, '%PDF-1.4\n');
created.push(outside);
const external = store.add({ title: '外部', files: [{ path: outside, name: 'outside.pdf' }] });
const file = indexPath(root);
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
let removed = 0;
try {
removed = store.removeMany([made[0].id, made[1].id], true).removed;
} finally {
fs.openSync = originalOpen;
}
assert.strictEqual(removed, 2);
assert.strictEqual(writes, 1, '批量移除应只写一次索引');
assert.strictEqual(store.list().length, 2);
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-0.pdf')), false);
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-2.pdf')), true);
// 用户原地引用的库外文件不能被删
assert.strictEqual(store.removeMany([external.id], true).removed, 1);
assert.strictEqual(fs.existsSync(outside), true, '库外文件不应被删除');
assert.deepStrictEqual(store.removeMany([], true), { removed: 0 });
assert.deepStrictEqual(store.removeMany(['missing'], false), { removed: 0 });
});
test('explicit tag creation enforces the existing catalog limit', () => {
const root = freshRoot('limit');
const now = Date.now();
+5 -3
View File
@@ -69,13 +69,14 @@ test('handles mixed file and directory inputs while skipping unsupported and non
const inFolder = write(root, path.join('folder', 'comic.cbz'));
const azw = write(root, path.join('folder', 'legacy.azw'));
const direct = write(root, 'notes.txt');
const markdown = write(root, path.join('folder', 'guide.MD'));
write(root, path.join('folder', 'cover.jpg'));
write(root, 'README.md');
write(root, 'README.rtf');
fs.mkdirSync(path.join(root, 'empty'));
const result = await discover([
path.join(root, 'missing.pdf'),
path.join(root, 'README.md'),
path.join(root, 'README.rtf'),
path.join(root, 'empty'),
direct,
folder
@@ -83,7 +84,8 @@ test('handles mixed file and directory inputs while skipping unsupported and non
assert.deepStrictEqual(
result.map((record) => record.path),
[inFolder, azw, direct].map((value) => fs.realpathSync(value)).sort()
[inFolder, azw, markdown, direct].map((value) => fs.realpathSync(value)).sort(),
'md 与 txt 都能进内置阅读器,必须和其他图书格式一样被本地导入发现'
);
});
+178 -2
View File
@@ -81,6 +81,182 @@ test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
assert.ok(segment.indexOf('annotations.forget', guard) > guard);
});
test('批量整理与移除走单次索引提交,不按条目循环 IPC', () => {
for (const [channel, method] of [['library:updateMany', 'updateMany'], ['library:removeMany', 'removeMany']]) {
const start = mainSrc.indexOf(`ipcMain.handle('${channel}'`);
assert.ok(start > 0, `缺少 ${channel}`);
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
assert.ok(segment.includes(`library.${method}(`), `${channel} 应调用 library.${method}`);
}
const removeStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
const removeSegment = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
// 与单本移除一致:默认保留阅读资料,只有显式勾选才清理
assert.match(removeSegment, /deleteReadingData\s*===\s*true/);
const uiSrc = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'library.js'), 'utf8');
assert.match(uiSrc, /window\.api\.library\.updateMany\(/);
assert.match(uiSrc, /window\.api\.library\.removeMany\(/);
assert.ok(!/for\s*\(const item of targets\)[\s\S]{0,200}library\.(update|remove)\(/.test(uiSrc),
'渲染层不应再逐条发 IPC');
});
test('孤立阅读资料只报告不自动删除,清理需分别勾选', () => {
const start = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
assert.ok(start > 0, '缺少孤立资料对账通道');
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const report = mainSrc.slice(start, end < 0 ? undefined : end);
// 对账必须以真实书库条目为准,不能凭文件名猜
assert.match(report, /library\.list\(\)/);
assert.match(report, /readerStore\.orphanReport\(/);
assert.match(report, /annotations\.orphanReport\(/);
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
assert.ok(purgeStart > 0, '缺少孤立资料清理通道');
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
const purge = mainSrc.slice(purgeStart, purgeEnd < 0 ? undefined : purgeEnd);
assert.match(purge, /scope\.annotations\s*===\s*true/);
assert.match(purge, /scope\.notes\s*===\s*true/);
assert.match(purge, /scope\.chats\s*===\s*true/);
assert.ok(purge.indexOf('library.list()') >= 0, '清理前必须重新对账,不能信任渲染层传来的 ID');
});
test('AI 会话随书籍删除一并清理,孤立会话纳入对账', () => {
const start = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
assert.ok(start > 0, '缺少批量移除通道');
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const remove = mainSrc.slice(start, end < 0 ? undefined : end);
assert.match(remove, /aiSessions\.forgetMany\(/);
// 会话删完要回收图片,否则内容寻址的图片永远没有引用者来释放
assert.match(remove, /collectAiImages\(\)/);
const reportStart = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
const reportEnd = mainSrc.indexOf('ipcMain.handle(', reportStart + 20);
assert.match(mainSrc.slice(reportStart, reportEnd), /aiSessions\.orphanReport\(/);
});
test('笔记独立窗口:开窗前重新对账,窗口内只能读自己已开的标签', () => {
const openStart = mainSrc.indexOf("ipcMain.handle('notes:openWindow'");
assert.ok(openStart > 0, '缺少笔记开窗通道');
const openEnd = mainSrc.indexOf('ipcMain.handle(', openStart + 20);
const open = mainSrc.slice(openStart, openEnd < 0 ? undefined : openEnd);
// 目标由 findNote 重新对账,不能直接把渲染层传来的 ID 拿去开窗
assert.match(open, /findNote\(entryId,\s*noteId\)/);
assert.match(open, /noteWindow\.open\(note\.entryId,\s*note\.id/);
const findStart = mainSrc.indexOf('function findNote');
const find = mainSrc.slice(findStart, findStart + 500);
assert.match(find, /readerStore\.listNotes\(/, '对账必须以 store 里真实存在的笔记为准');
const getStart = mainSrc.indexOf("ipcMain.handle('notes:getOne'");
const getEnd = mainSrc.indexOf('ipcMain.handle(', getStart + 20);
const getOne = mainSrc.slice(getStart, getEnd < 0 ? undefined : getEnd);
// 多标签后授权是「在标签集内」,不是「等于某一条」
assert.match(getOne, /noteWindow\.ownsNote\(event\.sender,\s*noteId\)/);
assert.match(getOne, /无权读取其它笔记/);
});
test('笔记标签集只能由渲染层收窄,新增必须走 open 对账', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
const start = src.indexOf('function setTabs');
assert.ok(start > 0, '缺少 setTabs');
const body = src.slice(start, src.indexOf('\n}', start));
// 允许渲染层往标签集里塞 ID,等于让它自己扩权:
// 谎报持有某条笔记后 notes:getOne 就会放行
assert.doesNotMatch(body, /openNotes\.set\(/, 'setTabs 不能新增标签');
assert.match(body, /openNotes\.delete\(/, 'setTabs 只做收窄');
assert.match(body, /isNoteSender\(wc\)/);
});
test('笔记窗口取消关闭后要复位 closePending 并撤掉看门狗', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
const start = src.indexOf('function cancelClose');
assert.ok(start > 0, '缺少 cancelClose:取消后窗口会再也关不掉');
const body = src.slice(start, src.indexOf('\n}', start));
assert.match(body, /clearTimeout\(closeTimer\)/, '不撤看门狗会在十秒后销毁带未保存内容的窗口');
assert.match(body, /closePending = false/);
assert.match(src, /ipcMain|module\.exports[\s\S]*cancelClose/);
});
test('笔记删除或书籍移除后独立窗口必须退场', () => {
const removeStart = mainSrc.indexOf("ipcMain.handle('reader:removeNote'");
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
const remove = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
// 窗口留着的话,它下一次保存会把已经删掉的笔记整条写回去
assert.match(remove, /noteWindow\.closeFor\(noteId\)/);
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
assert.match(mainSrc.slice(purgeStart, purgeEnd), /noteWindow\.closeForEntries\(/);
const removeManyStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
const removeManyEnd = mainSrc.indexOf('ipcMain.handle(', removeManyStart + 20);
assert.match(mainSrc.slice(removeManyStart, removeManyEnd), /noteWindow\.closeForEntries\(list\)/);
});
test('三处窗口广播都覆盖笔记独立窗口', () => {
// 漏掉任意一处,笔记窗口就收不到笔记变更或主题切换,界面与其它窗口不一致
for (const fn of ['notifyNotesChanged', 'applyWindowIcons', 'notifyUiThemeChanged']) {
const start = mainSrc.indexOf(`function ${fn}(`);
assert.ok(start > 0, `缺少 ${fn}`);
const body = mainSrc.slice(start, start + 420);
assert.match(body, /noteWindow\.all\(\)/, `${fn} 未覆盖笔记窗口`);
}
});
test('AI 会话按轮次落盘:先取历史再写提问,失败与取消都保留残片', () => {
const start = mainSrc.indexOf("ipcMain.handle('ai:run'");
assert.ok(start > 0, '缺少 AI 运行通道');
const end = mainSrc.indexOf('function settleAiAssistant', start);
const run = mainSrc.slice(start, end < 0 ? undefined : end);
// 顺序是硬约束:先 historyFor 再 appendUser,反了当前提问会被当成自己的历史发两遍
const historyAt = run.indexOf('historyFor(');
const appendAt = run.indexOf('appendUser(');
assert.ok(historyAt > 0 && appendAt > historyAt, '必须在写入本轮提问之前取历史');
// 同一会话禁止并发,否则两轮同时写同一个文件会互相覆盖
assert.match(run, /run\.sessionId\s*===\s*chatId/);
// 存的是提问本身,不是整篇正文
assert.match(run, /text:\s*aiTurnTitle\(task,\s*question\)/);
assert.match(run, /hash:\s*aiSessions\.hashContext\(body\)/);
// 已经流出来的残片必须落盘,界面上看到的半截回答不能一重开就消失
assert.match(run, /streamed\s*\+=\s*piece/);
assert.match(run, /text:\s*streamed/);
assert.ok(!/text:\s*''\s*,\s*\n\s*cancelled/.test(run), '取消时不能把残片写成空串');
// 落盘失败不能把已经拿到的回答变成请求失败
const settleStart = mainSrc.indexOf('function settleAiAssistant');
const settle = mainSrc.slice(settleStart, settleStart + 400);
assert.match(settle, /try\s*\{[\s\S]*finishAssistant\([\s\S]*catch/);
});
test('阅读器关闭书籍后通知书库刷新,且只接受阅读器发来的上报', () => {
const start = mainSrc.indexOf("ipcMain.handle('reader:entryClosed'");
assert.ok(start > 0, '缺少关闭上报通道');
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
assert.match(segment, /isReaderSender\(event\.sender\)/);
assert.match(segment, /notifyLibraryChanged\(\)/);
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 进度与批注异步落盘,先排空再通知,否则书库读到的还是旧数据
const drainTab = shell.indexOf('async function closeTab');
const drainEnd = shell.indexOf('\nasync function', drainTab + 20);
const closeTabBody = shell.slice(drainTab, drainEnd < 0 ? undefined : drainEnd);
assert.ok(closeTabBody.indexOf('await drainTabWrites(tab)') > 0);
assert.ok(closeTabBody.indexOf('notifyEntryClosed()') > closeTabBody.indexOf('await drainTabWrites(tab)'),
'通知必须排在写入排空之后');
const drainAll = shell.slice(shell.indexOf('async function drainAllTabWrites'));
assert.ok(drainAll.indexOf('await drainTabWrites(tab)') > 0);
assert.ok(drainAll.indexOf('notifyEntryClosed()') > drainAll.indexOf('await drainTabWrites(tab)'),
'关闭整个窗口也要在排空后通知');
});
test('书库列表附带阅读记录中的最近阅读时间', () => {
const start = mainSrc.indexOf("ipcMain.handle('library:list'");
const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
@@ -325,10 +501,10 @@ test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌',
assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
});
test('内置阅读器允许 PDF、EPUB无 DRM Kindle 容器并保留外部回退', () => {
test('内置阅读器允许 PDF、EPUB无 DRM Kindle 容器与纯文本并保留外部回退', () => {
assert.match(
mainSrc,
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/
);
assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
+269
View File
@@ -0,0 +1,269 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const adapterFile = path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs');
const src = fs.readFileSync(adapterFile, 'utf8');
// pdf-adapter.mjs 直接 import vendor 的 pdf.min.mjs,在 Node 里加载会因缺 DOMMatrix 而抛错,
// 所以只把不依赖 DOM 的钳制段落切出来求值,断言的仍是生产源码本身。
function loadPureBlock() {
const start = src.indexOf('const MAX_CANVAS_SIDE');
const end = src.indexOf('const CSS = `');
assert.ok(start >= 0 && end > start, '找不到画布上限段落,钳制代码可能被移动或删除');
const block = src.slice(start, end).replace(/^export /gm, '');
assert.doesNotMatch(
block,
/\b(window|document|navigator)\b/,
'钳制逻辑必须是纯函数,一旦依赖 DOM 就无法在单测里做真实数值断言'
);
return new Function(`${block}\nreturn { clampCanvasSize, clampRenderQuality };`)();
}
const { clampCanvasSize, clampRenderQuality } = loadPureBlock();
const MAX_SIDE = 16384;
const MAX_AREA = 268435456;
function assertWithinLimits(out, label) {
assert.ok(
out.width <= MAX_SIDE && out.height <= MAX_SIDE,
`${label} 单边 ${out.width}x${out.height} 超过 ${MAX_SIDE},浏览器会静默给出不可用画布,整页空白且不报错`
);
assert.ok(
out.width * out.height <= MAX_AREA,
`${label} 面积 ${out.width * out.height} 超过 ${MAX_AREA},同样会静默失败`
);
assert.ok(
out.width >= 1 && out.height >= 1,
`${label} 钳制后出现 ${out.width}x${out.height}0 尺寸画布会让 getContext 之后的绘制全部丢弃`
);
}
test('A4 常规缩放与超采样不触发钳制', () => {
// 612x792pt 的 A4 @ scale1.2,倍率 21468x1900,远在上限内
const out = clampCanvasSize(612 * 1.2, 792 * 1.2, 2);
assert.strictEqual(out.clamped, false, '常规页被误钳会白白牺牲清晰度');
assert.strictEqual(out.quality, 2, '未触发上限时必须原样保留名义倍率');
assert.strictEqual(out.width, 1468, `期望 1468 实际 ${out.width}`);
assert.strictEqual(out.height, 1900, `期望 1900 实际 ${out.height}`);
assertWithinLimits(out, 'A4@1.2x2');
});
test('A4 最高缩放叠加 dpr3 仍不触发钳制', () => {
// 实测 8929x12628(113M)是可用的,钳制不该把这一档也压下去
const out = clampCanvasSize(612 * 5, 792 * 5, 3);
assert.strictEqual(out.clamped, false, '这一档实测可用,钳制过度会让高缩放反而更糊');
assert.strictEqual(out.width, 9180, `期望 9180 实际 ${out.width}`);
assert.strictEqual(out.height, 11880, `期望 11880 实际 ${out.height}`);
assertWithinLimits(out, 'A4@5x3');
});
test('2000pt 大幅面 @ scale5 dpr2 被钳回上限内', () => {
// 期望 backing 20000x14000280M),实测不可用
const out = clampCanvasSize(2000 * 5, 1400 * 5, 2);
assert.strictEqual(out.clamped, true, '280M 画布必须被识别为超限,否则用户只看到空白页且没有任何提示');
assertWithinLimits(out, '2000pt@5x2');
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限以保留最大清晰度,实际 ${out.width}`);
assert.ok(out.quality < 2, `生效倍率 ${out.quality} 应低于名义倍率 2`);
});
test('4000pt 图纸 @ scale3 dpr2 被钳回上限内', () => {
// 期望 backing 24000x18000432M),实测不可用
const out = clampCanvasSize(4000 * 3, 3000 * 3, 2);
assert.strictEqual(out.clamped, true, '432M 画布必须被识别为超限');
assertWithinLimits(out, '4000pt@3x2');
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限,实际 ${out.width}`);
assert.strictEqual(out.height, 12288, `期望 12288 实际 ${out.height}`);
});
test('钳制后的实际比例如实报告,供 render 的 transform 使用', () => {
const cssWidth = 4000 * 3;
const cssHeight = 3000 * 3;
const out = clampCanvasSize(cssWidth, cssHeight, 2);
// transform 用名义倍率而不是取整后的实际比例,画面会错位或只画出一角
assert.strictEqual(out.scaleX, out.width / cssWidth, 'scaleX 必须等于 backing 宽除以 CSS 宽');
assert.strictEqual(out.scaleY, out.height / cssHeight, 'scaleY 必须等于 backing 高除以 CSS 高');
assert.ok(
Math.abs(out.scaleX - out.scaleY) < 1e-6,
`等比钳制下 x/y 比例应基本一致,实际 ${out.scaleX} / ${out.scaleY}`
);
});
test('短边的取整损失也要反映到比例上,不能直接沿用生效倍率', () => {
// 长边正好压到上限时它的比例恰等于生效倍率,只有短边能暴露 floor 带来的偏差
const out = clampCanvasSize(16000, 17000, 1);
assert.strictEqual(out.scaleX, out.width / 16000, 'scaleX 必须按 floor 之后的实际宽算');
assert.notStrictEqual(
out.scaleX,
out.quality,
`短边 ${out.width}px 的实际比例应当略小于生效倍率 ${out.quality},直接沿用会让内容画出画布边界`
);
assert.strictEqual(out.scaleY, out.height / 17000, 'scaleY 必须按 floor 之后的实际高算');
});
test('取整误差不会让某一边掉到 0 像素', () => {
const out = clampCanvasSize(100000, 1, 1);
assertWithinLimits(out, '极端长条页');
assert.strictEqual(out.height, 1, `期望至少 1px,实际 ${out.height}`);
assert.strictEqual(out.scaleY, 1, 'scaleY 必须按补足到 1px 后的实际比例报告,否则内容会被压到画布外');
});
test('长边超限而面积未超限的页也被钳', () => {
// 20000x2000 = 40M,面积远未超限,只有单边超限
const out = clampCanvasSize(20000, 2000, 1);
assert.strictEqual(out.clamped, true, '只超单边同样不可用,不能只看面积');
assertWithinLimits(out, '超长单边');
assert.strictEqual(out.width, MAX_SIDE, `期望 ${MAX_SIDE} 实际 ${out.width}`);
});
test('接近正方形的超大页同时压住面积与单边', () => {
// 16000x17000 = 272M,面积超限且短边已经贴着上限,是最容易只压一头的形状
const out = clampCanvasSize(16000, 17000, 1);
assert.strictEqual(out.clamped, true, '272M 面积必须被识别为超限');
assertWithinLimits(out, '16000x17000');
assert.strictEqual(out.height, MAX_SIDE, `长边应压到上限,实际 ${out.height}`);
});
test('面积约束是真在起作用,而不是被单边约束顺带盖住', () => {
// 当前常量下 MAX_AREA 恰好等于 MAX_SIDE 的平方,压住长边就顺带压住了面积,
// 面积检查的价值只有在上限常量按别的浏览器口径调整时才显现。
// 这里把单边上限换成 65535 重新求值,确认删掉面积项会立刻放出 600M 的画布。
const start = src.indexOf('const MAX_CANVAS_SIDE');
const end = src.indexOf('const CSS = `');
const block = src.slice(start, end)
.replace(/^export /gm, '')
.replace('const MAX_CANVAS_SIDE = 16384;', 'const MAX_CANVAS_SIDE = 65535;');
const wide = new Function(`${block}\nreturn clampCanvasSize;`)();
const out = wide(30000, 20000, 1);
assert.ok(
out.width * out.height <= MAX_AREA,
`面积 ${out.width * out.height} 超过 ${MAX_AREA}:面积约束没有独立生效,只靠单边约束挡不住扁平的大幅面页`
);
assert.strictEqual(out.clamped, true, '面积超限也必须如实上报,否则外壳无法解释清晰度为何被降');
});
test('大范围尺寸与倍率组合下上限恒成立', () => {
const sides = [1, 200, 612, 792, 1190, 2384, 5000, 10000, 20000, 40000];
const qualities = [1, 1.25, 1.5, 2, 2.5, 3, 4];
const scales = [0.25, 1, 1.2, 2, 3, 5];
for (const w of sides) {
for (const h of sides) {
for (const q of qualities) {
for (const s of scales) {
const out = clampCanvasSize(w * s, h * s, q);
const label = `${w}x${h} @scale${s} @${q}x`;
assertWithinLimits(out, label);
assert.ok(
out.quality <= q + 1e-9,
`${label}:生效倍率 ${out.quality} 不该超过名义倍率 ${q}`
);
assert.strictEqual(out.scaleX, out.width / (w * s), `${label}scaleX 与实际 backing 宽不符`);
assert.strictEqual(out.scaleY, out.height / (h * s), `${label}scaleY 与实际 backing 高不符`);
}
}
}
}
});
test('非法尺寸与倍率退回安全值而不是抛错或产出 NaN', () => {
for (const bad of [undefined, null, NaN, 0, -5, 'abc', Infinity]) {
const out = clampCanvasSize(bad, bad, bad);
assert.ok(
Number.isInteger(out.width) && Number.isInteger(out.height),
`尺寸 ${String(bad)} 产出了非整数 ${out.width}x${out.height}canvas.width 赋 NaN 会静默变 0`
);
assertWithinLimits(out, `非法输入 ${String(bad)}`);
}
});
test('renderQuality 只接受 1 到 4,非法值回退到 1', () => {
assert.strictEqual(clampRenderQuality(1), 1);
assert.strictEqual(clampRenderQuality(2), 2);
assert.strictEqual(clampRenderQuality(4), 4);
assert.strictEqual(clampRenderQuality(1.5), 1.5, '允许非整数档位,1.5x 实测已能显著降低误差');
assert.strictEqual(clampRenderQuality(8), 4, '超过 4 倍换不来可感知的清晰度,只会成倍吃显存');
assert.strictEqual(clampRenderQuality(0.5), 1, '低于 1 会比现状更糊');
for (const bad of [undefined, null, NaN, 'abc', {}, Infinity, -Infinity]) {
assert.strictEqual(
clampRenderQuality(bad),
1,
`非法值 ${String(bad)} 必须回退到 1,回退到 NaN 会让整块 backing 计算失效`
);
}
});
test('源码锁死画布上限常量与两条约束', () => {
assert.match(src, /const MAX_CANVAS_SIDE = 16384;/, 'Chromium 单边上限,改动前必须先实测');
assert.match(src, /const MAX_CANVAS_AREA = 268435456;/, 'Chromium 总面积上限');
assert.match(
src,
/Math\.min\(want, bySide, byArea\)/,
'单边与面积两条约束都要参与,只留一条在上限常量变动后就会漏放超限画布'
);
assert.match(src, /MAX_CANVAS_SIDE \/ Math\.max\(cssWidth, cssHeight\)/, '单边约束按长边算');
assert.match(src, /Math\.sqrt\(MAX_CANVAS_AREA \/ \(cssWidth \* cssHeight\)\)/, '面积约束按开方算');
assert.match(src, /Math\.max\(1, Math\.floor\(cssWidth \* applied\)\)/, '钳制后至少保留 1px');
assert.match(src, /Math\.max\(1, Math\.floor\(cssHeight \* applied\)\)/, '钳制后至少保留 1px');
});
test('源码锁死超采样倍率的取值与生效方式', () => {
assert.match(
src,
/Math\.max\(1, Math\.min\(4, n\)\)/,
'renderQuality 必须被 clamp 到 1..4'
);
// 一次锁死整条链路:dpr 取大而不是叠乘、期望倍率必须过钳制、画布尺寸只能来自钳制结果
assert.match(
src,
/const dpr = window\.devicePixelRatio \|\| 1;\s*\n\s*const wanted = Math\.max\(dpr, clampRenderQuality\(renderQuality\)\);\s*\n\s*const fit = clampCanvasSize\(vp\.width, vp\.height, wanted\);\s*\n\s*p\.canvas\.width = fit\.width;\s*\n\s*p\.canvas\.height = fit\.height;/,
'有效倍率必须是 max(dpr, renderQuality) 且画布尺寸只能取钳制结果,绕过任一步都会重新引入静默空白页'
);
assert.strictEqual(
[...src.matchAll(/Math\.max\(dpr, clampRenderQuality\(renderQuality\)\)/g)].length,
2,
'renderPage 与 renderStats 必须用同一个有效倍率公式,否则界面报告的清晰度和实际渲染的不一致'
);
assert.match(
src,
/transform: identity \? null : \[fit\.scaleX, 0, 0, fit\.scaleY, 0, 0\]/,
'transform 必须用钳制后的实际比例,用名义倍率会画错位或只画出一角'
);
assert.doesNotMatch(
src,
/transform: dpr === 1 \? null : \[dpr, 0, 0, dpr, 0, 0\]/,
'旧的 dpr 直接当 transform 的写法在钳制生效时会画错'
);
});
test('源码锁死倍率变化触发全页重建', () => {
assert.match(
src,
/if \(nextScale !== scale \|\| nextQuality !== renderQuality\) \{\s*\n\s*epoch\+\+;/,
'倍率变化不走 epoch++ 重建,同屏会残留旧清晰度的页'
);
assert.match(
src,
/nextQuality = opts && opts\.renderQuality !== undefined\s*\n?\s*\? clampRenderQuality\(opts\.renderQuality\)\s*\n?\s*: renderQuality/,
'缺省 renderQuality 时必须沿用当前值,否则每次渲染都会把用户设置重置成 1'
);
assert.match(src, /renderStats\(\)/, 'renderStats 是外壳读取当前清晰度状态的唯一正当出口');
assert.doesNotMatch(src, /window\.__test/, '不允许为测试往生产代码加全局钩子');
});
test('AI 截图路径同样受画布上限保护', () => {
assert.match(
src,
/const fit = clampCanvasSize\(area\.width, area\.height, wantScale\);/,
'大幅面页在 renderScale 下限 1 时画布等于页面点尺寸,MediaBox 异常的文件会顶到上限'
);
assert.match(src, /const renderScale = fit\.quality;/, '截图的 viewport 与 transform 必须用钳制后的倍率');
assert.match(src, /canvas\.width = fit\.width;/, '截图画布尺寸直接取钳制结果');
// 图像只保留一条 JPEG 编码路径,钳制不该顺手引入格式回退
assert.deepStrictEqual(
[...src.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]),
[],
'编码仍应集中在 visual-context.mjs,适配器里不该出现新的编码路径'
);
});
+28
View File
@@ -93,6 +93,34 @@ test('不同条目的阅读数据互相隔离', () => {
assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
});
test('孤立阅读资料按笔记数报告并可批量回收', () => {
const s = freshStore();
s.setProgress('kept', { kind: 'pdf', page: 2 }, 0.5);
s.addNote('kept', { text: '保留的笔记' });
s.addNote('gone_a', { text: '甲一' });
s.addNote('gone_a', { text: '甲二' });
s.addBookmark('gone_b', { locator: { kind: 'pdf', page: 3 } });
s.addStandaloneNote({ text: '与书籍无关的独立笔记' });
const orphans = s.orphanReport(['kept']);
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').notes, 2);
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_b').bookmarks, 1);
// 独立笔记本没有对应书籍,永远不算孤立
assert.ok(!orphans.some((o) => o.entryId === s.STANDALONE_ENTRY_ID));
assert.strictEqual(s.forgetMany(orphans.map((o) => o.entryId)), 2);
assert.strictEqual(s.getState('kept').notes.length, 1);
assert.strictEqual(s.getState('kept').progress.percent, 0.5);
assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记'));
assert.deepStrictEqual(s.orphanReport(['kept']), []);
assert.strictEqual(s.forgetMany([]), 0);
// 即使显式点名,也不能删掉独立笔记本:它没有对应书籍,永远不是可回收对象
assert.strictEqual(s.forgetMany([s.STANDALONE_ENTRY_ID]), 0);
assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记'));
});
test('getState 返回副本,外部改动不污染存储', () => {
const s = freshStore();
s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } });
+710
View File
@@ -0,0 +1,710 @@
// TXT / Markdown 适配器单测。适配器本体是渲染层 ESM,这里用 jsdom 提供真实的
// window / document / DOMParser,并加载仓库里真正会随包发布的 vendor 脚本,
// 这样断言的是「真正交给 epub 适配器渲染的产物」,而不是桩。
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const SRC = path.join(__dirname, '..', 'ui', 'reader', 'text-adapter.mjs');
const VENDOR = path.join(__dirname, '..', 'ui', 'vendor');
const XHTML = 'application/xhtml+xml';
function setupDom() {
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!doctype html><html><body></body></html>');
const win = dom.window;
win.JSZip = require(path.join(VENDOR, 'jszip.min.js'));
win.markdownit = require(path.join(VENDOR, 'markdown-it.min.js'));
const purifyFactory = require(path.join(VENDOR, 'purify.min.js'));
win.DOMPurify = typeof purifyFactory === 'function' ? purifyFactory(win) : purifyFactory;
for (const key of ['window', 'document', 'DOMParser', 'XMLSerializer', 'NodeFilter', 'Node', 'Range']) {
globalThis[key] = key === 'window' ? win : win[key];
}
return win;
}
const win = setupDom();
const mod = import('../ui/reader/text-adapter.mjs');
function u8(...parts) {
const buffers = parts.map((part) => (typeof part === 'string'
? Buffer.from(part, 'utf8')
: Buffer.from(part)));
return new Uint8Array(Buffer.concat(buffers));
}
function utf16Bytes(text, littleEndian, bom) {
const out = [];
if (bom) out.push(...(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]));
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
const hi = code >> 8;
const lo = code & 0xff;
out.push(...(littleEndian ? [lo, hi] : [hi, lo]));
}
return new Uint8Array(out);
}
// tidy 与 epub 适配器里的同名函数一致,textOf 返回的是 tidy 之后的文本
function tidy(s) {
return String(s == null ? '' : s)
.replace(/\r/g, '')
.replace(/[ \t\f\v\u00a0]+/g, ' ')
.replace(/ ?\n ?/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function novel(sections) {
const parts = [];
for (let i = 1; i <= sections; i++) {
parts.push(`${i}章 标题${i}\n【标记${String(i).padStart(4, '0')}】这一节的正文内容,用来验证全文不缺不重。\n`);
}
return parts.join('\n');
}
async function readChapter(zipBytes, index) {
const zip = await win.JSZip.loadAsync(zipBytes);
const file = zip.file(`text/chapter-${index}.xhtml`);
assert.ok(file, `产物里应有 text/chapter-${index}.xhtml`);
return file.async('text');
}
function parseXhtml(text) {
const doc = new win.DOMParser().parseFromString(text, XHTML);
assert.ok(doc && doc.body && !doc.querySelector('parsererror'),
'生成的章节必须是合法 XHTML,否则 epub 适配器会退回容错 HTML 解析,行为不可预测');
return doc;
}
function auditUntrusted(doc, where) {
for (const selector of ['script', 'iframe', 'object', 'embed', 'img', 'link', 'meta', 'form', 'base', 'a']) {
assert.strictEqual(doc.querySelectorAll(selector).length, 0,
`${where} 不允许出现 <${selector}>:这是不可信内容的注入面`);
}
for (const el of doc.querySelectorAll('*')) {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
assert.ok(!name.startsWith('on'),
`${where} 出现事件属性 ${name},脚本会在阅读器里执行`);
assert.ok(!['href', 'src', 'xlink:href', 'srcset', 'poster', 'style', 'formaction'].includes(name),
`${where} 出现可发起加载或导航的属性 ${name}`);
assert.ok(!/(?:javascript|vbscript|data|file):/i.test(attr.value),
`${where} 属性 ${name} 里出现危险协议`);
}
}
}
/* --- 编码检测与解码 --- */
test('UTF-8 与 UTF-16 的 BOM 都能正确剥离,BOM 不得留在正文里', async () => {
const { decodeTextBytes } = await mod;
const text = '中文标题\n正文第一行\n';
const plain = decodeTextBytes(u8(text));
assert.strictEqual(plain.text, text, 'UTF-8 无 BOM 必须原样解出');
assert.strictEqual(plain.encoding, 'utf-8');
assert.strictEqual(plain.confident, true);
const withBom = decodeTextBytes(u8([0xef, 0xbb, 0xbf], text));
assert.strictEqual(withBom.text, text, 'UTF-8 BOM 必须被剥掉,留着会变成正文首个不可见字符');
assert.ok(!withBom.text.includes('\ufeff'), 'U+FEFF 不能出现在正文里');
const le = decodeTextBytes(utf16Bytes(text, true, true));
assert.strictEqual(le.text, text, 'UTF-16LE 带 BOM 必须正确解出中文');
assert.strictEqual(le.encoding, 'utf-16le');
assert.ok(!le.text.includes('\ufeff'));
const be = decodeTextBytes(utf16Bytes(text, false, true));
assert.strictEqual(be.text, text, 'UTF-16BE 带 BOM 必须正确解出中文');
assert.strictEqual(be.encoding, 'utf-16be');
assert.ok(!be.text.includes('\ufeff'));
});
test('双 BOM 也要剥干净:TextDecoder 只吃掉第一个', async () => {
const { decodeTextBytes } = await mod;
// 某些编辑器另存为会叠一层 BOM。TextDecoder 只认最前面那个,第二个会变成正文首字符,
// 章节标题匹配随即失效,目录莫名少一章。
const text = '第1章 标题\n正文\n';
assert.strictEqual(decodeTextBytes(u8([0xef, 0xbb, 0xbf], [0xef, 0xbb, 0xbf], text)).text, text);
const le = utf16Bytes(text, true, true);
assert.strictEqual(decodeTextBytes(u8([0xff, 0xfe], le)).text, text);
});
test('无 BOM 的 UTF-16LE 靠 NUL 分布嗅探,不能当成乱码', async () => {
const { decodeTextBytes } = await mod;
const text = 'Chapter 1\nHello world, this is plain ASCII text stored as UTF-16LE.\n';
const out = decodeTextBytes(utf16Bytes(text, true, false));
assert.strictEqual(out.encoding, 'utf-16le');
assert.strictEqual(out.text, text);
});
test('GBK 字节走 gb18030 解码,不产生替换字符', async () => {
const { decodeTextBytes } = await mod;
// 「这是一段简体中文测试」的 GBK 编码
const gbk = new Uint8Array([
0xd5, 0xe2, 0xca, 0xc7, 0xd2, 0xbb, 0xb6, 0xce, 0xbc, 0xf2,
0xcc, 0xe5, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4,
0x0a
]);
const out = decodeTextBytes(gbk);
assert.strictEqual(out.text, '这是一段简体中文测试\n', 'GBK 必须解成可读中文');
assert.strictEqual(out.encoding, 'gb18030');
assert.strictEqual(out.confident, true);
assert.ok(!out.text.includes('\ufffd'), '解对了就不该有 U+FFFD');
});
test('解不出任何像样编码时标记 confident=false,不让用户对着乱码猜', async () => {
const { decodeTextBytes } = await mod;
// 0x81 后跟 0x1f 在 GBK / Big5 里都是非法尾字节,cp1252 里则落进 C1 控制区
const noise = new Uint8Array(512);
for (let i = 0; i < noise.length; i++) noise[i] = i % 2 ? 0x1f : 0x81;
const out = decodeTextBytes(noise);
assert.strictEqual(out.confident, false, '低置信度必须上报,否则界面会静默显示乱码');
for (const [name, bytes] of [
['UTF-8 中文', u8('正常的中文文本内容。\n')],
['GBK 中文', new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0x0a])],
['纯 ASCII', u8('Plain english text.\n')]
]) {
assert.strictEqual(decodeTextBytes(bytes).confident, true,
`${name} 不能被误判成低置信度,否则提示会变成噪声,用户就不再看它了`);
}
});
test('打分只认确定是正文的字符,拉丁扩展区乱码一律不得正分', async () => {
const { textScore, decodeTextBytes } = await mod;
const chinese = '这是一段正常的简体中文正文内容。';
const mojibake = Buffer.from(chinese, 'utf8').toString('latin1');
assert.ok(textScore(chinese) > 0, 'ASCII 与中文正文必须是正分');
assert.ok(textScore('Plain ASCII sentence.') > 0, 'ASCII 正文必须是正分');
// 乱码必须 <= 0 而不只是「低于正确解码」。一个汉字按 cp1252 摊成三个拉丁扩展字符,
// 只要这些字符拿到任何正分,乱码就靠字符数优势翻盘:实测把 0xa0..0x24f 记 1 分后,
// 同一段 GBK 正文的 windows-1252 得分从 0 涨到 320,与 gb18030 打平并靠顺序取胜。
for (const [name, text] of [
['UTF-8 中文按 cp1252 解出的乱码', mojibake],
['纯拉丁扩展区噪声', new TextDecoder('windows-1252')
.decode(new Uint8Array(Array.from({ length: 300 }, (_, i) => 0xa0 + (i % 0x40))))]
]) {
assert.ok(textScore(text) <= 0,
`${name} 得分为 ${textScore(text)},必须 <= 0,否则乱码会冒充识别成功`);
}
const gbk = new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4, 0x0a]);
assert.strictEqual(decodeTextBytes(gbk).encoding, 'gb18030',
'简体中文是本应用主场,不能被 windows-1252 的乱码抢走');
});
test('空文件与超限文件都有确定行为', async () => {
const { decodeTextBytes, MAX_TEXT_BYTES } = await mod;
const empty = decodeTextBytes(new Uint8Array(0));
assert.strictEqual(empty.text, '');
assert.strictEqual(empty.confident, true);
assert.throws(
() => decodeTextBytes(new Uint8Array(MAX_TEXT_BYTES + 1)),
/超过 64 MB/,
'超限必须抛中文错误,界面直接展示这句话'
);
});
test('CR LF 归一,C0 控制符被剔除', async () => {
const { decodeTextBytes } = await mod;
const out = decodeTextBytes(u8('第一行\r\n第二行\r第三行\u0000\u0007\n'));
assert.strictEqual(out.text, '第一行\n第二行\n第三行\n',
'C0 控制符留在正文会让生成的 XHTML 解析失败,正文随即退回容错解析');
const tabbed = decodeTextBytes(u8('列一\t列二\n'));
assert.strictEqual(tabbed.text, '列一\t列二\n', 'Tab 是合法 XML 字符,不能顺手删掉');
});
/* --- 章节切分 --- */
test('TXT 按中文章节标题切分,边界落在标题行首', async () => {
const { splitPlainText } = await mod;
const source = novel(6);
const out = splitPlainText(source);
assert.strictEqual(out.chapters.length, 6, '6 个「第N章」应切成 6 章');
assert.deepStrictEqual(out.chapters.map((c) => c.label),
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6'],
'章节名必须取自正文里的标题行');
assert.strictEqual(out.chapters[0].start, 0);
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source,
'各章拼回来必须与原文逐字相同,否则全文一定缺字或重字');
for (const chapter of out.chapters) {
assert.strictEqual(source.slice(chapter.start, chapter.end), chapter.text, '偏移必须与切片一致');
}
});
test('第一个章节标题之前的正文单独成章,不能被丢掉', async () => {
const { splitPlainText } = await mod;
const preamble = '书名:某本小说\n作者:某人\n版权声明若干。\n\n';
const source = preamble + novel(4);
const out = splitPlainText(source);
assert.strictEqual(out.chapters.length, 5, '开头 + 4 章 = 5 章');
assert.strictEqual(out.chapters[0].label, '开头');
assert.strictEqual(out.chapters[0].text, preamble,
'首个标题之前的内容必须完整保留,否则序言与版权页在全文里凭空消失');
assert.strictEqual(out.chapters[1].label, '第1章 标题1');
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
});
test('标题不足三个时不认,按长度分节,序号从 1 开始', async () => {
const { splitPlainText } = await mod;
const source = `第1章 只有一个标题\n${'一二三四五六七八九十'.repeat(200)}\n`.repeat(1);
const out = splitPlainText(source, { target: 500 });
assert.ok(out.chapters.length >= 1);
assert.match(out.chapters[0].label, /^第 1 节$/, '未识别到章节结构时用「第 N 节」');
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
});
test('空文件、纯空白、单行超长都不崩且能拼回原文', async () => {
const { splitPlainText } = await mod;
const empty = splitPlainText('');
assert.strictEqual(empty.chapters.length, 1);
assert.strictEqual(empty.chapters[0].text, '');
assert.strictEqual(empty.chapters[0].label, '正文');
const blank = splitPlainText(' \n\n\t \n');
assert.ok(blank.chapters.length >= 1);
assert.strictEqual(blank.chapters.map((c) => c.text).join(''), ' \n\n\t \n');
const huge = 'x'.repeat(200000);
const one = splitPlainText(huge, { target: 1000, max: 5000 });
assert.ok(one.chapters.length > 1, '没有换行的超长文本也必须切开,否则单章会撑爆 DOM');
assert.strictEqual(one.chapters.map((c) => c.text).join(''), huge);
assert.ok(one.chapters.every((c) => c.text.length <= 5000), '每章不得超过上限');
assert.ok(one.chapters.slice(1).every((c) => /$/.test(c.label)), '硬切出来的后续片段要标注(续)');
});
test('章节数不超过上限,避免打出上万个 zip 条目', async () => {
const { splitPlainText } = await mod;
const source = novel(400);
const out = splitPlainText(source, { maxChapters: 50 });
assert.ok(out.chapters.length <= 50, `实际 ${out.chapters.length} 章,超过 maxChapters`);
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source, '合并章节不能丢正文');
});
/* --- Markdown 结构 --- */
test('Markdown 标题生成正确的目录层级', async () => {
const { createMarkdownRenderer, splitMarkdown, buildTocEntries, markdownHeadings } = await mod;
const md = createMarkdownRenderer(win.markdownit);
const source = [
'# 总标题', '', '开场白。', '',
'## 第一节', '', '内容一。', '',
'### 一点一', '', '内容二。', '',
'## 第二节', '', '内容三。', '',
'```', '# 代码块里的井号不是标题', '```', ''
].join('\n');
const headings = markdownHeadings(source, md);
assert.deepStrictEqual(headings.map((h) => [h.level, h.label]), [
[1, '总标题'], [2, '第一节'], [3, '一点一'], [2, '第二节']
], '代码块里的 # 不能被当成标题');
const split = splitMarkdown(source, md);
assert.deepStrictEqual(split.chapters.map((c) => c.label),
['总标题', '第一节', '第二节'], '默认按 h1/h2 切章,h3 留在章内');
assert.strictEqual(split.title, '总标题');
assert.strictEqual(split.chapters.map((c) => c.text).join(''), source);
const { entries, anchorsByChapter } = buildTocEntries(split.chapters, split.headings, split.splitLevel);
assert.deepStrictEqual(entries.map((e) => [e.label, e.depth, e.chapter]), [
['总标题', 0, 0], ['第一节', 1, 1], ['一点一', 2, 1], ['第二节', 1, 2]
], 'depth 必须等于标题级别减一,目录才能正确缩进');
assert.strictEqual(anchorsByChapter.get(1)[0], '', '章首标题跳章即可,不需要锚点');
assert.ok(anchorsByChapter.get(1)[1], '章内标题必须有锚点,否则目录只能跳到章首');
});
test('锚点数量与渲染出的标题数不符时不打 id,避免锚点指到错误标题', async () => {
const { createMarkdownRenderer, markdownChapterXhtml, resolveServices } = await mod;
const md = createMarkdownRenderer(win.markdownit);
const services = resolveServices();
const chapter = { label: '甲', text: '# 一\n\n正文\n\n## 二\n', start: 0, end: 20 };
const mismatched = markdownChapterXhtml(chapter, services, md, ['only-one']);
assert.ok(!/\bid="/.test(mismatched),
'章节边界可能落进代码围栏,使章内真实标题数与预估不符;此时按序号打 id 会让目录跳到错误的标题,宁可放弃锚点');
assert.strictEqual(parseXhtml(mismatched).querySelectorAll('h1,h2').length, 2, '放弃锚点不等于放弃正文');
const matched = markdownChapterXhtml(chapter, services, md, ['a1', 'a2']);
const ids = Array.from(parseXhtml(matched).querySelectorAll('[id]')).map((el) => el.getAttribute('id'));
assert.deepStrictEqual(ids, ['a1', 'a2'], '数量对得上时必须按文档顺序打 id');
});
test('没有 h1/h2 的 Markdown 回退到按长度分节,标题列表仍然可用', async () => {
const { createMarkdownRenderer, splitMarkdown } = await mod;
const md = createMarkdownRenderer(win.markdownit);
const source = `### 只有三级标题\n\n${'正文内容。'.repeat(50)}\n`;
const out = splitMarkdown(source, md);
assert.ok(out.chapters.length >= 1, '无 h1/h2 也要有章节');
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
assert.strictEqual(out.headings.length, 1, 'h3 仍应出现在目录里');
assert.strictEqual(out.title, '只有三级标题');
const bare = splitMarkdown('只有一行正文,没有任何标题。', md);
assert.strictEqual(bare.chapters.length, 1);
assert.ok(bare.chapters[0].label, '无标题时也要有可展示的章节名');
});
test('目录嵌套标记闭合正确,epub 目录解析器按 li > ol 递归', async () => {
const { navMarkup } = await mod;
const html = navMarkup([
{ label: 'A', depth: 0, href: 'a' },
{ label: 'A1', depth: 1, href: 'a1' },
{ label: 'A2', depth: 1, href: 'a2' },
{ label: 'B', depth: 0, href: 'b' }
]);
assert.strictEqual(html,
'<ol><li><a href="a">A</a><ol><li><a href="a1">A1</a></li><li><a href="a2">A2</a></li></ol></li><li><a href="b">B</a></li></ol>');
const doc = new win.DOMParser().parseFromString(`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
assert.ok(!doc.querySelector('parsererror'), '目录标记必须是合法 XHTML');
assert.strictEqual(doc.querySelectorAll('li').length, 4);
assert.strictEqual(doc.querySelectorAll('li > ol > li').length, 2, '子级必须挂在父级 li 里');
const skipped = navMarkup([{ label: 'X', depth: 3, href: 'x' }]);
assert.strictEqual(skipped, '<ol><li><a href="x">X</a></li></ol>', '首条深度跳跃要收敛到顶层,不能生成孤立 ol');
});
test('任意 depth 序列生成的目录都是闭合的合法 XHTML,且条目一个不少', async () => {
const { navMarkup } = await mod;
const cases = [
[0], [1], [0, 1, 2, 3, 2, 1, 0], [2, 0, 2], [0, 0, 0],
[0, 2, 1, 3, 0], [1, 1, 0, 5, 0], [3, 3, 3], []
];
for (const depths of cases) {
const entries = depths.map((depth, i) => ({ label: `L${i}`, depth, href: `h${i}` }));
const html = navMarkup(entries);
const doc = new win.DOMParser().parseFromString(
`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
assert.ok(!doc.querySelector('parsererror'),
`depth 序列 [${depths}] 生成了非法 XHTML${html};标记不闭合会让整个 nav.xhtml 解析失败,目录直接退化成按章编号`);
assert.strictEqual(doc.querySelectorAll('li').length, depths.length,
`depth 序列 [${depths}] 的目录条目数应为 ${depths.length}`);
assert.strictEqual(doc.querySelectorAll('ol > li').length, depths.length,
`depth 序列 [${depths}] 里每个 li 都必须直接挂在 ol 下`);
}
});
/* --- 净化 --- */
const EVIL_MARKDOWN = [
'# 标题',
'',
'<script>alert(1)</script>',
'',
'<img src=x onerror=alert(1)>',
'',
'[链接](javascript:alert(1))',
'',
'<iframe src="http://evil.example"></iframe>',
'',
'<div onmouseover="alert(1)">悬停</div>',
'',
'![图注](http://evil.example/tracker.png)',
'',
'[正常外链](https://example.com/page)',
'',
'<svg><use xlink:href="http://evil.example/x#a" /></svg>',
''
].join('\n');
test('sanitizeMarkdownFragment 直接吃裸 HTML 也必须净化干净', async () => {
const { sanitizeMarkdownFragment, resolveServices } = await mod;
const services = resolveServices();
const fragment = sanitizeMarkdownFragment([
'<h1 id="x">标题</h1>',
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'<iframe src="http://evil.example"></iframe>',
'<a href="javascript:alert(1)">链接</a>',
'<p style="background:url(http://evil.example/x)">样式</p>',
'<object data="x"></object><embed src="x" /><form action="x"></form>',
'<p onclick="alert(1)">点我</p>'
].join(''), services);
const host = win.document.createElement('div');
host.appendChild(fragment);
const doc = parseXhtml(`<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head><body>${new win.XMLSerializer().serializeToString(host)}</body></html>`);
auditUntrusted(doc, '净化后的片段');
assert.ok(doc.body.textContent.includes('标题'), '净化不能把正常正文一起吃掉');
});
test('恶意 Markdown 渲染出的章节里没有可执行内容', async () => {
const { buildTextEpub } = await mod;
const built = await buildTextEpub(u8(EVIL_MARKDOWN), { format: 'md' });
const xhtml = await readChapter(built.bytes, 0);
const doc = parseXhtml(xhtml);
auditUntrusted(doc, 'Markdown 章节');
const text = doc.body.textContent;
assert.ok(text.includes('alert(1)'),
'危险内容应被转义成可见文字而不是静默删除,用户才知道原文写了什么');
assert.ok(text.includes('图片:图注'), '图片必须换成占位符,绝不外链加载');
assert.ok(text.includes('正常外链'), '外链文字要保留,只是不再可点');
assert.ok(!/<script|<iframe|<img|<svg/i.test(xhtml), '序列化结果里不允许出现这些标签');
});
test('源码锁死两道防线:markdown-it html:false 与放行名单里没有 href/src', async () => {
const src = fs.readFileSync(SRC, 'utf8');
assert.match(src, /markdownit\(\{[\s\S]{0,400}?html: false/,
'html:false 是第一道防线,去掉后裸 HTML 会直接进入渲染管线');
const allowed = src.match(/const ALLOWED_ATTR = Object\.freeze\(\[([^\]]*)\]\)/);
assert.ok(allowed, '必须显式声明放行属性名单');
assert.ok(!/href|src|style|srcset|on[a-z]/i.test(allowed[1]),
`放行名单不能含加载或导航类属性,当前为 ${allowed[1]}`);
assert.match(src, /RETURN_DOM_FRAGMENT: true/, '必须以片段形式取回净化结果,避免二次解析引入 mXSS');
});
/* --- 端到端:契约方法 --- */
test('TXT 走完整管线:章节数、目录、locator kind 与全文', async () => {
const { createTextAdapter } = await mod;
const source = novel(6);
const adapter = createTextAdapter('txt');
const progress = [];
try {
const info = await adapter.load(u8(source), { onProgress: (p) => progress.push(p) });
assert.strictEqual(info.format, 'txt');
assert.strictEqual(info.mode, 'plain');
assert.strictEqual(info.encoding, 'utf-8');
assert.strictEqual(info.chapterCount, 6);
assert.strictEqual(info.title, '未命名文本',
'首行本身是章节标题时不能拿来当书名,否则书库标题会变成「第1章」');
assert.ok(progress.length && progress[progress.length - 1] === 1, 'onProgress 必须走到 1');
assert.ok(progress.every((p) => p >= 0 && p <= 1), '进度必须落在 0..1');
const toc = await adapter.toc();
assert.strictEqual(toc.length, 6);
assert.deepStrictEqual(toc.map((t) => t.label),
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6']);
assert.deepStrictEqual(toc.map((t) => t.locator.chapter), [0, 1, 2, 3, 4, 5]);
for (const entry of toc) {
assert.strictEqual(entry.locator.kind, 'txt', 'locator.kind 必须是 txt,外壳靠它判断书签与笔记');
assert.strictEqual(entry.depth, 0);
}
const label = adapter.locatorLabel({ kind: 'txt', chapter: 2, offset: 0 });
assert.strictEqual(label, '第3章 标题3');
} finally {
adapter.destroy();
}
});
test('首行是书名时取作标题,是章节标题时不取', async () => {
const { splitPlainText } = await mod;
assert.strictEqual(splitPlainText(`某本小说的书名\n\n${novel(4)}`).title, '某本小说的书名');
assert.strictEqual(splitPlainText(novel(4)).title, '',
'首行是「第1章」时留空,交由上层用文件名兜底');
});
test("textOf(locator, 'document') 返回全文,不缺不重", async () => {
const { createTextAdapter } = await mod;
const source = novel(8);
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(source));
const full = await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
assert.strictEqual(full, tidy(source),
'AI 的「全文」范围完全依赖这条,少一个字就是静默数据丢失');
for (let i = 1; i <= 8; i++) {
const token = `【标记${String(i).padStart(4, '0')}`;
assert.strictEqual(full.split(token).length - 1, 1, `${token} 必须恰好出现一次`);
}
const positions = [];
for (let i = 1; i <= 8; i++) positions.push(full.indexOf(`【标记${String(i).padStart(4, '0')}`));
assert.deepStrictEqual(positions, [...positions].sort((a, b) => a - b), '全文顺序必须与原文一致');
const one = await adapter.textOf({ kind: 'txt', chapter: 3, offset: 0 }, 'chapter');
assert.ok(one.includes('【标记0004】'), '按章取文必须取到对应章');
assert.ok(!one.includes('【标记0005】'), '按章取文不得越界');
} finally {
adapter.destroy();
}
});
test('Markdown 走完整管线:标题渲染成 h1/h2,代码块与表格保留', async () => {
const { createTextAdapter } = await mod;
const source = [
'# 文档标题', '', '开场白。', '',
'## 列表与代码', '',
'- 第一项', '- 第二项', '',
'```js', 'const x = 1;', '```', '',
'> 引用一句话', '',
'| 列一 | 列二 |', '| --- | --- |', '| 1 | 2 |', '',
'**加粗**与 `行内代码`。', ''
].join('\n');
const adapter = createTextAdapter('md');
try {
const info = await adapter.load(u8(source));
assert.strictEqual(info.format, 'md');
assert.strictEqual(info.mode, 'markdown');
assert.strictEqual(info.title, '文档标题');
assert.strictEqual(info.chapterCount, 2);
const toc = await adapter.toc();
assert.deepStrictEqual(toc.map((t) => [t.label, t.depth]), [['文档标题', 0], ['列表与代码', 1]]);
assert.strictEqual(toc[0].locator.kind, 'md', 'Markdown 的 locator.kind 必须是 md');
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
for (const piece of ['文档标题', '开场白。', '第一项', '第二项', 'const x = 1;', '引用一句话', '列一', '加粗', '行内代码']) {
assert.ok(full.includes(piece), `全文里必须有「${piece}」,Markdown 渲染不能吞内容`);
}
assert.ok(!full.includes('```'), 'Markdown 记号应被渲染掉而不是原样留在正文里');
} finally {
adapter.destroy();
}
});
test('渲染后的块级元素在全文里彼此分行,AI 不会拿到糊成一团的文本', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('md');
try {
// 标题与紧随其后的正文之间原文没有空行,最容易被拼成「标题甲正文乙」
await adapter.load(u8('# 标题甲\n正文乙\n\n## 标题丙\n正文丁\n\n- 列表戊\n- 列表己\n'));
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
for (const [a, b] of [['标题甲', '正文乙'], ['标题丙', '正文丁'], ['列表戊', '列表己']]) {
assert.ok(!full.includes(a + b),
`${a}」与「${b}」被拼成了一个词,块级元素之间必须留分隔符,否则送给模型的全文语义错乱`);
assert.ok(new RegExp(`${a}\\n+${b}`).test(full), `${a}」与「${b}」之间应有换行`);
}
} finally {
adapter.destroy();
}
});
test('Markdown 章节渲染成真正的块级结构,不是纯文本', async () => {
const { buildTextEpub } = await mod;
const built = await buildTextEpub(u8([
'# 标题', '', '- 项', '', '```js', 'const x = 1;', '```', '',
'> 引用', '', '| a | b |', '| - | - |', '| 1 | 2 |', ''
].join('\n')), { format: 'md' });
const doc = parseXhtml(await readChapter(built.bytes, 0));
assert.strictEqual(doc.querySelectorAll('h1').length, 1, 'Markdown 标题必须渲染成 h1');
assert.strictEqual(doc.querySelectorAll('ul > li').length, 1);
assert.strictEqual(doc.querySelectorAll('pre > code').length, 1);
assert.strictEqual(doc.querySelectorAll('blockquote').length, 1);
assert.strictEqual(doc.querySelectorAll('table td').length, 2);
});
test('超大 Markdown 降级为纯文本,不让 markdown-it 整篇解析', async () => {
const { buildTextEpub, MARKDOWN_MAX_CHARS } = await mod;
const over = `# 标题\n\n${'abcde\n'.repeat(Math.ceil(MARKDOWN_MAX_CHARS / 6) + 100)}`;
assert.ok(over.length > MARKDOWN_MAX_CHARS);
const built = await buildTextEpub(u8(over), { format: 'md' });
assert.strictEqual(built.mode, 'plain', '超限时必须降级,否则整篇解析的开销与内存都不可控');
const under = await buildTextEpub(u8('# 标题\n\n正文。\n'), { format: 'md' });
assert.strictEqual(under.mode, 'markdown', '正常体量的 md 必须走渲染路径');
});
test('几十 MB 的 txt 切成小章,单章体积可控', async () => {
const { buildTextEpub, splitPlainText } = await mod;
const block = `第N章 标题\n${'这一段是压力测试用的中文正文。'.repeat(10)}\n\n`;
const source = block.repeat(2000).replace(/第N章/g, () => '第1章');
const split = splitPlainText(source);
assert.ok(split.chapters.length <= 4000, '章节数必须有上限,否则 zip 条目数失控');
assert.ok(split.chapters.every((c) => c.text.length <= 48000),
'单章必须足够小,一次性把整份文本塞进 DOM 会卡死渲染进程');
const built = await buildTextEpub(u8(source), { format: 'txt' });
assert.strictEqual(built.chapterCount, split.chapters.length);
assert.strictEqual(built.mode, 'plain');
});
test('nextLocator / prevLocator 在两端返回 null', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(novel(4)));
assert.strictEqual(adapter.prevLocator({ kind: 'txt', chapter: 0, offset: 0 }), null,
'第一章没有上一章,返回 null 外壳才会禁用按钮');
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 3, offset: 0 }), null,
'最后一章没有下一章');
const next = adapter.nextLocator({ kind: 'txt', chapter: 0, offset: 500 });
assert.deepStrictEqual(next, { kind: 'txt', chapter: 1, offset: 0 });
const prev = adapter.prevLocator({ kind: 'txt', chapter: 2, offset: 10 });
assert.deepStrictEqual(prev, { kind: 'txt', chapter: 1, offset: 0 });
assert.deepStrictEqual(adapter.nextLocator(null), { kind: 'txt', chapter: 1, offset: 0 },
'非法 locator 要按第 0 章处理,不能抛');
} finally {
adapter.destroy();
}
});
test('percentOf 与 locatorFromPercent 互为逆运算', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(novel(8)));
// 章内百分比需要章节长度,先取一次全文把长度缓存起来(外壳打开书后同样会发生)
await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 0, offset: 0 }), 0);
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 7, offset: 1e9 }), 1);
for (const p of [0, 0.125, 0.3, 0.5, 0.77, 1]) {
const locator = adapter.locatorFromPercent(p);
assert.strictEqual(locator.kind, 'txt');
const back = adapter.percentOf(locator);
assert.ok(Math.abs(back - p) < 0.02,
`百分比往返偏差过大:${p} -> ${JSON.stringify(locator)} -> ${back};进度条与书签会漂移`);
}
assert.deepStrictEqual(adapter.locatorFromPercent(-5), { kind: 'txt', chapter: 0, offset: 0 },
'越界百分比必须夹紧');
assert.strictEqual(adapter.locatorFromPercent(99).chapter, 7);
} finally {
adapter.destroy();
}
});
test('超过体积上限时 load 抛中文错误', async () => {
const { createTextAdapter, MAX_TEXT_BYTES } = await mod;
const adapter = createTextAdapter('txt');
try {
await assert.rejects(
() => adapter.load(new Uint8Array(MAX_TEXT_BYTES + 1)),
/超过 64 MB/,
'超限错误会原样显示给用户,必须是中文'
);
} finally {
adapter.destroy();
}
});
test('适配器暴露外壳依赖的全部方法,且 .md 后缀归一', async () => {
const { createTextAdapter, normalizeTextFormat } = await mod;
const adapter = createTextAdapter('txt');
try {
for (const name of [
'load', 'renderTo', 'toc', 'getSelection', 'textOf', 'locatorLabel',
'nextLocator', 'prevLocator', 'percentOf', 'locatorFromPercent',
'capturePinchAnchor', 'restorePinchAnchor', 'setLocatorChangeHandler',
'setTouchGestureHandler', 'visualViewportRect', 'destroy'
]) {
assert.strictEqual(typeof adapter[name], 'function', `外壳会调用 ${name},必须实现`);
}
assert.strictEqual(adapter.getSelection(), null, '未渲染时取选区应返回 null 而不是抛错');
assert.strictEqual(adapter.visualViewportRect(), null);
assert.strictEqual(adapter.capturePinchAnchor(0, 0), null);
} finally {
adapter.destroy();
}
assert.strictEqual(normalizeTextFormat('.MD'), 'md');
assert.strictEqual(normalizeTextFormat('markdown'), 'md');
assert.strictEqual(normalizeTextFormat('txt'), 'txt');
assert.strictEqual(normalizeTextFormat(undefined), 'txt');
});
test('destroy 之后再次 load 不残留上一本书的状态', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(novel(6)));
assert.strictEqual((await adapter.toc()).length, 6);
adapter.destroy();
assert.strictEqual(adapter.documentInfo(), null, 'destroy 必须清掉文档信息');
await adapter.load(u8(novel(3)));
assert.strictEqual((await adapter.toc()).length, 3, '重新 load 后目录必须是新书的');
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 2, offset: 0 }), null);
} finally {
adapter.destroy();
}
});
+351 -6
View File
@@ -307,8 +307,14 @@ test('AI 上下文提供无需选中的当前页与全文范围', () => {
// 全文必须提示可能超限,并且始终弹确认框
assert.match(shell, /可能超过模型限制/);
assert.match(shell, /全文可能超过模型的上下文限制/);
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
// 正文不再本地截断,文案必须如实说明"完整发送 + 超限由接口报错",
// 否则界面显示的字数与实际外发字数不一致(实测 40 页只发出 8 页)
assert.match(shell, /全文将完整发送/);
assert.doesNotMatch(shell, /保留首尾并截断/);
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
assert.doesNotMatch(client, /let body = clipContext\(text\)/);
assert.match(client, /上下文超出模型窗口/);
// 旧设置迁移,避免升级后回落成 selection
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
@@ -374,12 +380,13 @@ 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, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3、TXT、MD/);
assert.match(html, /书库导入与管理[\s\S]*TXT、MD、DJVU、FB2、CBZ、CBR/);
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
assert.match(readme, /## 支持格式/);
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
assert.match(readme, /TXT \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
assert.match(readme, /TXT \/ MD[\s\S]*Markdown/);
assert.match(readme, /DJVU \/ FB2 \/ CBZ \/ CBR/);
});
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
@@ -389,7 +396,7 @@ test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器
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(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/);
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
@@ -495,6 +502,66 @@ test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
});
test('笔记表单控件样式不外溢到工具栏,关联下拉框限宽', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const noteWindowCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
const fieldRule = css.match(/\.note-edit-form select[^{]*\{([^}]*margin-top[^}]*)\}/);
assert.ok(fieldRule, '找不到笔记表单控件规则');
// 画布工具栏与 Quill 工具栏都是 .note-edit-form 的后代,
// 漏掉任一 :not() 就会把 margin-top / width:100% 灌进工具栏,
// 表现为工具栏凭空高出一截、分组高度对不齐
assert.match(fieldRule[0], /:not\(\.canvas-note-root select\)/);
assert.match(fieldRule[0], /:not\(\.ql-toolbar select\)/);
const capRule = css.match(/\.note-edit-form select[^{]*\{([^}]*max-width[^}]*)\}/);
assert.ok(capRule, '关联书籍下拉框没有限宽');
assert.match(capRule[1], /max-width:\s*320px/);
assert.match(capRule[1], /min-width:\s*0/);
assert.match(capRule[0], /:not\(\.canvas-note-root select\)/);
const metaRule = noteWindowCss.match(/\.note-window-meta select[\s\S]*?\{([^}]*)\}/);
assert.ok(metaRule, '笔记窗口下拉框没有限宽规则');
assert.match(metaRule[1], /max-width:\s*260px/);
assert.match(metaRule[1], /min-width:\s*0/);
});
test('笔记窗口标题栏只有品牌名,没有副标题', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
const titlebar = html.match(/<div class="titlebar">[\s\S]*?<div class="titlebar-spacer">/);
assert.ok(titlebar, '找不到笔记窗口标题栏');
// style.css 的 .titlebar-left 不是 flex(只有 reader.css 是),
// 放同级的 brand-sub 会掉到品牌名下面一行,把标题栏顶高
assert.doesNotMatch(titlebar[0], /brand-sub/);
assert.doesNotMatch(html, /noteWindowSubtitle/);
assert.doesNotMatch(shell, /noteWindowSubtitle|subtitle/);
assert.match(titlebar[0], /<span>笔记<\/span>/);
});
test('笔记窗口多标签:自带标签条样式,非激活视图隐藏,存活编辑器有上限', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
assert.match(html, /class="doctabs"/, '缺少标签条');
assert.match(html, /id="noteDirtyModal"/, '缺少未保存确认框');
// note.html 不加载 reader.css,标签条样式必须在 note-window.css 里自带一份
assert.doesNotMatch(html, /reader\.css/);
assert.match(css, /\.doctabs\s*\{/, '标签条样式缺失,标签会退化成竖排文字');
assert.match(css, /\.note-tab-view\.inactive[\s\S]*?display:\s*none/);
// 只留一个可见视图,否则多个 Quill/画布实例同时可见会互相抢焦点
assert.match(shell, /MAX_LIVE_EDITORS\s*=\s*\d+/);
// 取消关闭必须真的把 cancelClose 发出去:主进程的 closePending 不复位,
// 下次点关闭会被当成"正在处理"忽略,而看门狗仍会销毁带未保存内容的窗口
const abortAt = shell.indexOf('async function abortClose');
assert.ok(abortAt > 0, '缺少 abortClose');
const abortBody = shell.slice(abortAt, shell.indexOf('\n}', abortAt));
assert.match(abortBody, /await api\.notes\.cancelClose\(\)/);
assert.doesNotMatch(abortBody, /if\s*\(\s*true\s*\)\s*return/);
assert.match(abortBody, /closing = false/);
// 脏判定必须比对序列化内容:靠 keydown/pointerdown 之类的交互事件会误报,
// 画布加载时的 1→2 版本归一化本身就会改一次内容
assert.match(shell, /baselineKey/);
assert.doesNotMatch(shell, /addEventListener\('pointerdown'[\s\S]{0,120}dirty/);
});
test('书架操作对键盘焦点可见', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
@@ -550,7 +617,124 @@ test('书库多选提供全选、批量整理与批量移除', () => {
// 批量移除沿用单本移除的两个可选项
assert.match(library, /id="bulkDelFiles"/);
assert.match(library, /id="bulkDelReadingData"/);
assert.match(library, /window\.api\.library\.remove\(item\.id, choice\)/);
// 一次 IPC 提交整批,不再逐条 remove:书库索引是整体重写的,循环会造成写放大
assert.match(library, /window\.api\.library\.removeMany\(targets\.map\(\(item\) => item\.id\), choice\)/);
assert.match(library, /window\.api\.library\.updateMany\(patches\)/);
});
test('卡片定位上下文不随多选状态消失', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
// 退出多选是同步移除 class,重绘要等 IPC;若定位上下文只在 select-mode 下建立,
// 残留的复选框会按视口定位飞到左上角标题上闪一下
assert.match(css, /\n\.card\s*\{[^}]*position:\s*relative/);
assert.doesNotMatch(css, /#libraryTab\.select-mode \.card\s*\{\s*position:\s*relative/);
assert.match(css, /#libraryTab:not\(\.select-mode\) \.card-select\s*\{\s*display:\s*none/);
});
test('多选复选框用原生外观,不套衬底色块', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const wrap = css.match(/\.card-select\s*\{([^}]*)\}/);
assert.ok(wrap, '缺少复选框容器样式');
// padding + 背景板会让 13px 的原生复选框看起来套了一圈很粗的边框,
// 浅色封面上的可见性改用投影解决
assert.doesNotMatch(wrap[1], /padding:\s*[1-9]/);
assert.doesNotMatch(wrap[1], /background:\s*rgba/);
const input = css.match(/\.card-select input\s*\{([^}]*)\}/);
assert.ok(input, '缺少复选框样式');
assert.match(input[1], /drop-shadow/);
});
test('封面完整显示且比例一致,留白由模糊层填充', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const coverRule = css.match(/\n\.card-cover\s*\{([^}]*)\}/);
assert.ok(coverRule, '缺少封面样式');
// cover 会按各自比例裁掉不同的边,同一批封面看起来缩放程度不一致
assert.match(coverRule[1], /center\/contain/);
assert.doesNotMatch(coverRule[1], /center\/cover/);
// ::before 垫模糊底,::after 画清晰的完整封面。
// 只用 ::before 的话它会盖住父元素自己的背景,封面整张变成模糊的
// 共用规则里 ::after 也单独占一行,所以不能靠 \n 区分;
// 按"独占一条规则"来取:选择器后面直接跟 { 且规则里带 z-index
const rules = [...css.matchAll(
/\.card-cover\[data-cover-state="ready"\]::(before|after)\s*\{([^}]*)\}/g
)].filter((m) => /z-index/.test(m[2]));
const before = rules.find((m) => m[1] === 'before');
const after = rules.find((m) => m[1] === 'after');
assert.ok(before && after, '缺少封面双层背景规则');
assert.match(before[2], /background-size:\s*cover/);
assert.match(before[2], /blur\(/);
assert.match(after[2], /background-size:\s*contain/);
// 模糊层必须在下、清晰层在上
assert.ok(
Number(before[2].match(/z-index:\s*(\d+)/)[1]) < Number(after[2].match(/z-index:\s*(\d+)/)[1]),
'模糊层盖住了清晰封面'
);
// 角标与占位文字要浮在两层背景之上
assert.match(css, /\.card-cover > \*\s*\{[^}]*z-index:\s*2/);
});
test('可内置阅读的格式在渲染层与 main.js 保持一致', () => {
const mainSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const readable = mainSrc.match(/const READABLE_EXT = new Set\(\[([^\]]*)\]\)/);
assert.ok(readable, '找不到 READABLE_EXT');
const exts = readable[1].match(/\.\w+/g).map((s) => s.slice(1)).sort();
// 渲染层漏掉格式不会报错,只是「阅读」按钮和封面点击静默消失,
// 用户看到的现象就是"内置阅读器打不开 txt"
for (const file of [
path.join(__dirname, '..', 'ui', 'views', 'library.js'),
path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs')
]) {
const src = fs.readFileSync(file, 'utf8');
const re = src.match(/READABLE_RE\s*=\s*\/\\\.\(([^)]*)\)\$\/i/);
assert.ok(re, `${path.basename(file)} 缺少 READABLE_RE`);
assert.deepStrictEqual(re[1].split('|').sort(), exts, `${path.basename(file)} 的可阅读格式与 main.js 不一致`);
}
});
test('状态、笔记与标签标识叠在封面上,不占用封面下方行', () => {
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
// 三种标识都必须在 .card-cover 内部,否则又会各占一行
const cover = library.match(/<div class="card-cover\$\{[\s\S]*?\n\s*<\/div>/);
assert.ok(cover, '封面结构缺失');
assert.match(cover[0], /class="library-card-tags"/);
// 下载状态在左下角,批注与笔记在右下角
assert.match(cover[0], /class="card-cover-badges start">\$\{badge\}/);
assert.match(cover[0], /class="card-cover-badges end">\$\{annotationBadge\}\$\{noteBadge\}/);
// 标识不能再出现在封面之后、标题周围
const afterCover = library.slice(library.indexOf('class="card-title"'));
assert.doesNotMatch(afterCover.slice(0, 400), /card-cover-badges|library-card-tags/);
const coverRule = css.match(/\.card-cover\s*\{([^}]*)\}/);
assert.match(coverRule[1], /position:\s*relative/);
assert.match(coverRule[1], /overflow:\s*hidden/);
const badgeWrap = css.match(/\.card-cover-badges\s*\{([^}]*)\}/);
assert.ok(badgeWrap, '缺少封面标识容器样式');
assert.match(badgeWrap[1], /position:\s*absolute/);
assert.match(badgeWrap[1], /bottom:\s*6px/);
// 标识浮在封面上,必须让点击穿透到封面的阅读入口
assert.match(badgeWrap[1], /pointer-events:\s*none/);
// 左右两组各占一半,避免笔记批注多时与左下角的下载状态叠在一起
assert.match(badgeWrap[1], /max-width:\s*calc\(50% - 8px\)/);
assert.match(css, /\.card-cover-badges\.start\s*\{\s*left:\s*6px/);
assert.match(css, /\.card-cover-badges\.end\s*\{\s*right:\s*6px/);
const tagsRule = css.match(/\.library-card-tags\s*\{([^}]*)\}/);
assert.match(tagsRule[1], /position:\s*absolute/);
assert.match(tagsRule[1], /top:\s*6px/);
assert.match(tagsRule[1], /pointer-events:\s*none/);
// 左上角留给多选复选框,标签宽度必须扣掉这块
assert.match(tagsRule[1], /max-width:\s*calc\(100% - 42px\)/);
// 允许换行会让三个长标签堆成三行盖住封面
assert.doesNotMatch(tagsRule[1], /flex-wrap:\s*wrap/);
assert.match(library, /class="library-card-tag" title="\$\{escapeHtml\(tag\)\}"/);
// 封面图案深浅不可控,衬底必须不透明
const badgeRule = css.match(/\n\.card-badge\s*\{([^}]*)\}/);
assert.match(badgeRule[1], /background:\s*var\(--bg-card\)/);
assert.doesNotMatch(badgeRule[1], /margin-top/);
});
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
@@ -564,6 +748,167 @@ test('书库长标题保持单行省略并提供完整悬浮提示', () => {
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
});
test('AI 面板以线程容器承载多轮对话气泡', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
// 结构约定要写在 HTML 里,集成测试与 CSS 都按 ai-thread / ai-msg 定位
assert.match(html, /id="aiOutput" class="ai-output ai-thread"/);
assert.match(shell, /box\.className = `ai-msg ai-msg-\$\{role\}`/);
assert.match(shell, /box\.dataset\.messageId = String\(message\.id \|\| ''\)/);
assert.match(shell, /body\.className = 'ai-msg-body'/);
assert.match(shell, /meta\.className = 'ai-msg-meta'/);
assert.match(css, /\.ai-msg-body\s*\{/);
assert.match(css, /\.ai-thread\s*\{[^}]*flex-direction:\s*column/);
// 新回答必须追加而不是覆盖:整块重写等于回到一问一答
assert.match(shell, /function appendAiMessage\(message\)/);
assert.match(shell, /el\.aiOutput\.appendChild\(node\)/);
});
test('AI 流式增量只重渲染正在生成的那一条气泡', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 线程里有几十条消息时,每 80ms 重解析全部 Markdown 会把界面拖死,
// 因此增量渲染只允许写 aiStreamNode 里的 .ai-msg-body
const render = shell.match(/function renderAiOutput\([\s\S]*?\n\}/);
assert.ok(render, '缺少 renderAiOutput');
assert.match(render[0], /aiStreamNode\.querySelector\('\.ai-msg-body'\)/);
assert.match(render[0], /renderMessageBody\(body,/);
assert.doesNotMatch(render[0], /AiMarkdown\.mount\(el\.aiOutput/);
assert.doesNotMatch(render[0], /renderAiThread\(\)/);
// 整篇重建只发生在换会话时,不能出现在节流的增量路径上
const schedule = shell.match(/function scheduleAiOutput\([\s\S]*?\n\}/);
assert.ok(schedule, '缺少 scheduleAiOutput');
assert.doesNotMatch(schedule[0], /renderAiThread\(\)/);
assert.doesNotMatch(schedule[0], /AiMarkdown\.mount\(el\.aiOutput/);
// 80ms 节流与「贴底才自动滚动」都要保留
assert.match(shell, /aiRenderTimer = window\.setTimeout\([\s\S]{0,120}\}, 80\)/);
assert.match(shell, /el\.aiOutput\.scrollHeight - el\.aiOutput\.scrollTop - el\.aiOutput\.clientHeight < 48/);
});
test('AI 增量按 messageId 路由到对应气泡', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const delta = shell.match(/unsubDelta = api\.ai\.onDelta\([\s\S]*?\n \}\);/);
assert.ok(delta, '缺少 onDelta 订阅');
// 只按 runId 过滤会让同一次运行里的旧气泡也收到增量,串成一团
assert.match(delta[0], /const messageId = d\.messageId \? String\(d\.messageId\) : ''/);
assert.match(delta[0], /messageId !== aiRun\.messageId\) return/);
assert.match(shell, /function adoptStreamingMessageId\(messageId\)/);
assert.match(shell, /aiRun\.messageId = String\(messageId\)/);
});
test('AI 会话管理提供新建、重命名、置顶、清空与删除', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSessionSelect"[^>]+title="切换当前书籍的对话会话"/);
assert.match(html, /id="aiSessionNewBtn"[^>]*>新建</);
assert.match(html, /id="aiSessionRenameBtn"[^>]*>重命名</);
assert.match(html, /id="aiSessionPinBtn"[^>]*>置顶</);
assert.match(html, /id="aiSessionClearBtn"[^>]*>清空</);
assert.match(html, /id="aiSessionDeleteBtn"[^>]*>删除</);
// 会话下拉按 pinned 优先、updatedAt 倒序,标题为空时显示「新会话」
assert.match(shell, /a\.pinned \? -1 : 1/);
assert.match(shell, /Number\(b\.updatedAt\) \|\| 0\) - \(Number\(a\.updatedAt\) \|\| 0\)/);
assert.match(shell, /return title \|\| '新会话'/);
// 一开书就建空会话会很快占满 100 个上限,必须延迟到首次提问
assert.match(shell, /async function ensureAiSession\(\)/);
assert.match(shell, /if \(aiSessionId\) return aiSessionId/);
const activateBlock = shell.match(/async function activate\(id\)[\s\S]*?\n\}/);
assert.ok(activateBlock);
assert.match(activateBlock[0], /refreshAiSessions\(\)/);
assert.doesNotMatch(activateBlock[0], /sessions\.create\(/);
// 生成中禁止切会话、删会话与发新问题
assert.match(shell, /el\.aiSessionSelect\.disabled = busy \|\| !hasEntry/);
assert.match(shell, /el\.aiSessionDeleteBtn\.disabled = busy \|\| !hasSession/);
assert.match(shell, /function aiBusy\(busy\)[\s\S]{0,400}syncAiSessionControls\(\)/);
});
test('删除与清空 AI 会话都要走应用内二次确认', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSessionDeleteModal"[\s\S]*id="aiSessionDeleteConfirmBtn"[^>]*>删除会话</);
assert.match(html, /id="aiSessionClearModal"[\s\S]*id="aiSessionClearConfirmBtn"[^>]*>清空消息</);
assert.match(html, /id="aiSessionRenameModal"[\s\S]*id="aiSessionTitleInput"/);
// 对话记录删掉找不回来,必须先确认再调 remove/clear
const remove = shell.match(/async function deleteAiSession\(\)[\s\S]*?\n\}/);
assert.ok(remove, '缺少 deleteAiSession');
const removeConfirmAt = remove[0].indexOf('await confirmAiSessionDelete(');
const removeCallAt = remove[0].indexOf('sessions.remove(');
assert.ok(removeConfirmAt >= 0, '删除会话缺少二次确认,会一键抹掉全部对话记录');
assert.ok(removeCallAt >= 0, '删除会话未调用 sessions.remove');
assert.ok(removeConfirmAt < removeCallAt, '删除会话必须先弹二次确认再调 sessions.remove');
const clear = shell.match(/async function clearAiSession\(\)[\s\S]*?\n\}/);
assert.ok(clear, '缺少 clearAiSession');
const clearConfirmAt = clear[0].indexOf('await confirmAiSessionClear(');
const clearCallAt = clear[0].indexOf('sessions.clear(');
assert.ok(clearConfirmAt >= 0, '清空会话缺少二次确认,消息删掉找不回来');
assert.ok(clearCallAt >= 0, '清空会话未调用 sessions.clear');
assert.ok(clearConfirmAt < clearCallAt, '清空会话必须先弹二次确认再调 sessions.clear');
assert.match(shell, /function confirmAiSessionDelete\(title\)[\s\S]{0,400}aiSessionDeleteModal\.classList\.remove\('hidden'\)/);
});
test('AI 会话调用 api.ai.sessions 契约并带上 sessionId 发送', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /api\.ai && api\.ai\.sessions \? api\.ai\.sessions : null/);
assert.match(shell, /sessions\.list\(\{ entryId \}\)/);
assert.match(shell, /sessions\.create\(\{ entryId, title: '', documentKey: tab\.documentKey \|\| null \}\)/);
assert.match(shell, /sessions\.rename\(aiSessionId, title\)/);
assert.match(shell, /sessions\.setPinned\(aiSessionId, next\)/);
assert.match(shell, /sessions\.remove\(removed\)/);
assert.match(shell, /sessions\.clear\(aiSessionId\)/);
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_THREAD_LIMIT \}\)/);
// sessionId 不下发就退化成不落盘的一次性提问,线程无法续接
assert.match(shell, /api\.ai\.run\(\{\s*\n\s*runId,\s*\n\s*sessionId,/);
assert.match(shell, /res\.data\.assistantMessageId|ids\.assistantMessageId/);
});
test('AI 每条回答各自提供保存为笔记与复制', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /save\.className = 'tb-btn sm ai-msg-save'/);
assert.match(shell, /copy\.className = 'tb-btn ghost sm ai-msg-copy'/);
assert.match(shell, /saveAiNote\(aiResultOf\(box\.dataset\.messageId\)\)/);
assert.match(shell, /copyAiMessage\(box\.dataset\.messageId\)/);
// locator 取该条对应 user 消息的 contextRefquote 取那条问题文本
assert.match(shell, /if \(aiThread\[i\]\.role === 'user'\) \{ ask = aiThread\[i\]; break; \}/);
assert.match(shell, /locator: \(ref && ref\.locator\) \|\| null/);
assert.match(shell, /quote: ask \? String\(ask\.text \|\| ''\) : ''/);
assert.match(shell, /async function saveAiNote\(target\)/);
});
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /const parts = \[scopeName\(ref\.scope\)\]/);
assert.match(shell, /parts\.push\(`\$\{chars\.toLocaleString\(\)\} 字`\)/);
assert.match(shell, /`上下文:\$\{parts\.join\(' · '\)\}`/);
assert.match(shell, /if \(message\.truncated\) flags\.push\('内容已裁剪'\)/);
assert.match(shell, /if \(message\.cancelled\) flags\.push\('已停止生成'\)/);
assert.match(shell, /box\.classList\.add\('ai-msg-error'\)/);
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 轮对话已省略/);
});
test('AI 线程里的模型输出全部经 AiMarkdown 渲染,不直接写 innerHTML', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 渲染层 CSP 挡不住 DOM 注入,模型文本必须过 AiMarkdown 内的 DOMPurify
assert.match(shell, /function renderMessageBody\(body, source\)[\s\S]{0,300}window\.AiMarkdown\.mount\(body, source\)/);
assert.match(shell, /else renderMessageBody\(body, message\.text\)/);
assert.doesNotMatch(shell, /\.innerHTML\s*=/);
// 用户输入按纯文本落地,同样不经 HTML 解析
assert.match(shell, /if \(role === 'user'\) body\.textContent = String\(message\.text \|\| ''\)/);
// 链接拦截仍挂在整个线程容器上,新增气泡里的链接不会漏掉
assert.match(shell, /el\.aiOutput\.addEventListener\('click', activateAiLink\)/);
assert.match(shell, /el\.aiOutput\.addEventListener\('auxclick', activateAiLink\)/);
assert.match(shell, /if \(!link \|\| !el\.aiOutput\.contains\(link\)\) return/);
});
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
const library = fs.readFileSync(libFile, 'utf8');
assert.match(library, /data-act="read" role="button" tabindex="0"/);
+78
View File
@@ -0,0 +1,78 @@
// 元数据落盘的统一入口。
//
// rename 只保证目录项替换是原子的,不保证被替换的数据已经到磁盘:
// 断电后可能出现"改名成功但文件内容是一段空洞"的结果,长度正常、内容全零。
// 因此改名前必须先 fsync 数据本身,改名后再 fsync 目录让目录项落盘。
// Windows 不允许对目录取句柄,那一步失败时忽略即可。
const fs = require('fs');
const path = require('path');
function writeSynced(file, data, encoding = 'utf8') {
const fd = fs.openSync(file, 'w');
try {
fs.writeFileSync(fd, data, encoding === null ? undefined : { encoding });
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
}
function syncDirectory(dir) {
let fd = null;
try {
fd = fs.openSync(dir, 'r');
fs.fsyncSync(fd);
} catch (e) {
// Windows 无法 fsync 目录;其它平台失败也不该让写入整体失败
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
}
}
}
// 写入 dest,保留一份 .bak 以便下次读取时恢复。
// 失败时清理临时文件,并在目标已被改走时把备份换回去。
function writeJson(dest, value) {
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
writeSynced(temp, JSON.stringify(value, null, 2));
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
syncDirectory(path.dirname(dest));
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响写入结果 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
throw e;
}
}
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
function writeBytesExclusive(dest, bytes) {
const temp = `${dest}.tmp`;
fs.mkdirSync(path.dirname(dest), { recursive: true });
const fd = fs.openSync(temp, 'wx');
try {
fs.writeFileSync(fd, bytes);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(temp, dest);
syncDirectory(path.dirname(dest));
}
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive };
+1
View File
@@ -8,6 +8,7 @@ const BOOK_EXT = new Set([
'azw',
'azw3',
'txt',
'md',
'djvu',
'fb2',
'cbz',
+87 -35
View File
@@ -13,6 +13,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const { fetchWithProxy } = require('../sources/http');
const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
@@ -20,7 +21,7 @@ const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHT
const SCHEMA_VERSION = 4;
const MAX_TAGS = 50;
const MAX_TAG_LENGTH = 64;
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr']);
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr']);
let rootDir = null;
let items = null;
@@ -129,36 +130,14 @@ function load() {
}
function persistTo(dir, value, shelfValue = shelves || [], tagValue = tags || []) {
const dest = path.join(dir, 'library.json');
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
temp,
JSON.stringify({
version: SCHEMA_VERSION,
shelves: shelfValue,
tags: tagValue,
items: value
}, null, 2),
'utf-8'
);
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (cleanupError) { /* 保留备份不影响提交 */ }
}
atomic.writeJson(path.join(dir, 'library.json'), {
version: SCHEMA_VERSION,
shelves: shelfValue,
tags: tagValue,
items: value
});
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollbackError) { /* 下次加载时会从 .bak 恢复 */ }
throw new Error(`书库索引写入失败: ${e.message || e}`);
}
}
@@ -788,11 +767,7 @@ function update(id, patch) {
load();
const it = items.find((x) => x.id === id);
if (!it) throw new Error('条目不存在');
const next = { ...patch };
if (next.files) next.files = next.files.map(normalizeFile);
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) next.shelfId = normalizeShelfId(next.shelfId);
const next = normalizedPatch(patch);
const updated = { ...it, ...next, updatedAt: Date.now() };
const nextItems = items.map((x) => x.id === id ? updated : x);
const organizationChanged = Object.prototype.hasOwnProperty.call(next, 'tags')
@@ -929,6 +904,82 @@ function attachFile(id, filePath) {
return expand(updated);
}
function normalizedPatch(patch) {
const next = { ...patch };
if (next.files) next.files = next.files.map(normalizeFile);
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) {
next.shelfId = normalizeShelfId(next.shelfId);
}
return next;
}
// 批量整理走单次 commit:逐条 update 会把整个索引重写 N 遍,
// 几千条的书库里批量改动会卡住界面
function updateMany(patches) {
load();
if (!Array.isArray(patches) || !patches.length) return { updated: 0 };
const byId = new Map();
for (const entry of patches) {
if (!entry || entry.id == null) throw new Error('批量更新缺少条目 ID');
const id = String(entry.id);
if (!items.some((x) => x.id === id)) throw new Error('条目不存在');
byId.set(id, normalizedPatch(entry.patch || {}));
}
const now = Date.now();
let updated = 0;
const nextItems = items.map((x) => {
const patch = byId.get(x.id);
if (!patch) return x;
updated++;
return { ...x, ...patch, updatedAt: now };
});
commit(nextItems, true);
return { updated };
}
function removeMany(ids, deleteFiles) {
load();
if (!Array.isArray(ids) || !ids.length) return { removed: 0 };
const targets = ids.map((id) => String(id));
const unique = new Set(targets);
const doomed = items.filter((x) => unique.has(x.id));
const nextItems = items.filter((x) => !unique.has(x.id));
const staged = [];
if (deleteFiles) {
try {
for (const it of doomed) {
for (const f of it.files || []) {
const abs = toAbsolute(f.path);
if (!abs || !isWithin(rootDir, abs) || !fs.existsSync(abs)) continue;
const temp = `${abs}.deleting-${genId()}`;
fs.renameSync(abs, temp);
staged.push({ abs, temp });
}
}
} catch (e) {
for (const f of staged.reverse()) {
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
}
throw e;
}
}
try {
commit(nextItems, true);
} catch (e) {
for (const f of staged.reverse()) {
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
}
throw e;
}
for (const f of staged) {
try { fs.unlinkSync(f.temp); } catch (e) { /* 文件已移出书库,稍后可手动清理 */ }
}
for (const it of doomed) removeCoverFile(it.id);
return { removed: doomed.length };
}
function remove(id, deleteFiles) {
load();
const it = items.find((x) => x.id === id);
@@ -1262,7 +1313,8 @@ function importLegacy(legacyDir) {
module.exports = {
init, getRoot, filesDir, allocFilePath, sanitize,
list, get, findBySource, listShelves, listTags,
add, importLocal, update, remove, attachFile, addShelf, updateShelf, removeShelf,
add, importLocal, update, updateMany, remove, removeMany, attachFile,
addShelf, updateShelf, removeShelf,
addTag, updateTag, removeTag,
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
ensureCoverCached, setGeneratedCover, setChangeListener
+93 -25
View File
@@ -10,8 +10,8 @@ const { fetchWithProxy } = require('../sources/http');
const MAX_CHARS = 12000;
const MAX_QUESTION_CHARS = 4000;
// 上下文按字符数截断。中间挖空而不是尾部截断:
// 结论性内容常在末尾,只留开头会让模型答非所问
// 中间挖空而不是尾部截断:结论性内容常在末尾,只留开头会让模型答非所问。
// 正文默认不再走这里,只在有明确预算上限的场合(如多轮历史)显式调用
function clipContext(text, limit = MAX_CHARS) {
const s = String(text || '');
if (s.length <= limit) return s;
@@ -42,7 +42,7 @@ const TASKS = {
function buildPromptFromNormalized(task, text, question, visuals) {
const t = TASKS[task];
if (!t) throw new Error('不支持的任务类型: ' + task);
let body = clipContext(text);
let body = String(text || '');
const ocr = visuals
.filter((item) => item.ocr.include)
.map((item) => item.ocr.text.trim())
@@ -58,27 +58,60 @@ function buildPromptFromNormalized(task, text, question, visuals) {
return { system, userText, images };
}
function buildMessagesFromNormalized(task, text, question, visuals) {
// 历史轮只取 role 与 text,其余字段(尤其 images)一律忽略:
// 视觉模型按图块计费,重发历史图会让长会话费用随轮数累积,用户无从预期。
function normalizeHistory(history) {
if (!Array.isArray(history)) return [];
const items = [];
for (const entry of history) {
if (!entry) continue;
const role = entry.role === 'assistant' ? 'assistant' : 'user';
const text = String(entry.text || '').trim();
if (!text) continue;
const last = items[items.length - 1];
// 相邻同角色合并而不是丢弃:Anthropic 会直接 400,但丢内容比合并更糟
if (last && last.role === role) last.text = `${last.text}\n\n${text}`;
else items.push({ role, text });
}
// Anthropic 的 /messages 要求首条必须是 user
while (items.length && items[0].role === 'assistant') items.shift();
return items;
}
// 当前轮固定是 user,历史末条若也是 user 就会相邻同角色,把它并入当前轮正文。
function mergeHistory(history, currentText) {
const items = normalizeHistory(history);
const tail = items.length && items[items.length - 1].role === 'user' ? items.pop() : null;
return {
items,
currentText: tail ? `${tail.text}\n\n${currentText}` : currentText
};
}
function buildMessagesFromNormalized(task, text, question, visuals, history) {
const { system, userText, images } = buildPromptFromNormalized(task, text, question, visuals);
const merged = mergeHistory(history, userText);
const userContent = images.length
? [
{ type: 'text', text: userText },
{ type: 'text', text: merged.currentText },
...images.map((item) => ({
type: 'image_url',
image_url: { url: imageDataUrl(item.image) }
}))
]
: userText;
: merged.currentText;
return [
{ role: 'system', content: system },
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content: userContent }
];
}
function buildAnthropicPayload(cfg, prompt) {
function buildAnthropicPayload(cfg, prompt, history) {
const merged = mergeHistory(history, prompt.userText);
const content = prompt.images.length
? [
{ type: 'text', text: prompt.userText },
{ type: 'text', text: merged.currentText },
...prompt.images.map((item) => ({
type: 'image',
source: {
@@ -88,20 +121,24 @@ function buildAnthropicPayload(cfg, prompt) {
}
}))
]
: prompt.userText;
: merged.currentText;
return {
model: cfg.model,
system: prompt.system,
messages: [{ role: 'user', content }],
messages: [
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content }
],
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
stream: true
};
}
function buildResponsesPayload(cfg, prompt) {
function buildResponsesPayload(cfg, prompt, history) {
const merged = mergeHistory(history, prompt.userText);
const content = [
{ type: 'input_text', text: prompt.userText },
{ type: 'input_text', text: merged.currentText },
...prompt.images.map((item) => ({
type: 'input_image',
image_url: imageDataUrl(item.image)
@@ -110,7 +147,12 @@ function buildResponsesPayload(cfg, prompt) {
return {
model: cfg.model,
instructions: prompt.system,
input: [{ role: 'user', content }],
input: [
// 纯字符串是 Responses 输入消息的合法简写,同时绕开 input_text/output_text
// 的角色约束:input_text 不接受 assistantoutput_text 只出现在带 id 的输出项里。
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content }
],
temperature: cfg.temperature,
max_output_tokens: cfg.maxTokens,
stream: true,
@@ -118,8 +160,14 @@ function buildResponsesPayload(cfg, prompt) {
};
}
function buildMessages(task, text, question, visualContexts) {
return buildMessagesFromNormalized(task, text, question, normalizeVisualContexts(visualContexts));
function buildMessages(task, text, question, visualContexts, history) {
return buildMessagesFromNormalized(
task,
text,
question,
normalizeVisualContexts(visualContexts),
history
);
}
function endpointFor(baseUrl, protocol) {
@@ -144,24 +192,42 @@ function headersFor(cfg) {
return headers;
}
function payloadFor(cfg, task, text, question, visuals) {
function payloadFor(cfg, task, text, question, visuals, history) {
const prompt = buildPromptFromNormalized(task, text, question, visuals);
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt);
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt);
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt, history);
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt, history);
return {
model: cfg.model,
messages: buildMessagesFromNormalized(task, text, question, visuals),
messages: buildMessagesFromNormalized(task, text, question, visuals, history),
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
stream: true
};
}
// 正文不再由本地截断,超出模型窗口时只能由接口报错。各家措辞不同,
// 统一识别成一句可操作的中文提示,否则用户只会看到一串英文而不知道该缩小范围。
const CONTEXT_OVERFLOW_RE = /context[_\s-]?length|context window|maximum context|too many tokens|prompt is too long|reduce the length|input length|exceeds? the (?:maximum|context)/i;
function isContextOverflow(message, status) {
// 413 单看状态码就够:请求体过大只可能是上下文塞太多
if (status === 413) return true;
if (!CONTEXT_OVERFLOW_RE.test(String(message || ''))) return false;
return status === undefined || status === 400 || status === 422;
}
function overflowHint(message) {
return `上下文超出模型窗口,请把范围改小(如改用"当前页"或选中片段)或换用更大窗口的模型。接口原文:${message}`;
}
function parseErrorBody(text, status) {
try {
const j = JSON.parse(text);
const msg = (j.error && (j.error.message || j.error)) || j.message;
if (msg) return String(msg);
if (msg) {
const s = String(msg);
return isContextOverflow(s, status) ? overflowHint(s) : s;
}
} catch (e) { /* 非 JSON */ }
if (status === 401 || status === 403) return 'API Key 无效或没有权限';
if (status === 404) return '接口地址或模型名称不存在';
@@ -189,8 +255,8 @@ function streamFinished(protocol, event) {
}
// onDelta 每收到一段增量就回调一次;返回完整文本。
// signal 用于用户中途取消。
async function stream({ task, text, question, visualContexts, signal, onDelta }) {
// signal 用于用户中途取消。history 是本轮之前的历史轮次,只消费 role 与 text。
async function stream({ task, text, question, visualContexts, history, signal, onDelta }) {
const cfg = aiConfig.get();
const st = aiConfig.status();
if (!cfg.apiKey && !st.isLocal) throw new Error('尚未配置 API Key,请先在设置中填写');
@@ -203,7 +269,7 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
const res = await fetchWithProxy(endpointFor(cfg.baseUrl, cfg.protocol), {
method: 'POST',
headers: headersFor(cfg),
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals)),
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals, history)),
signal
});
@@ -231,11 +297,13 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
// 部分服务端把错误放在流里返回
if (j.error || j.type === 'error') {
const error = j.error || j;
throw new Error(error.message || String(error));
const message = error.message || String(error);
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
}
if (cfg.protocol === 'openai-responses' && ['response.failed', 'response.incomplete'].includes(j.type)) {
const error = j.response && (j.response.error || j.response.incomplete_details);
throw new Error((error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成');
const message = (error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成';
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
}
const piece = streamDelta(cfg.protocol, j);
if (piece) {
+155
View File
@@ -0,0 +1,155 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const ASSET_RE = /^img_[a-f0-9]{64}$/;
const MIME_TYPE = 'image/jpeg';
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
const MAX_TOTAL_BYTES = 256 * 1024 * 1024;
const GRACE_MS = 10 * 60 * 1000;
let rootDir = null;
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-ai-images');
}
function directory() {
if (rootDir) return rootDir;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'reader-ai-images');
}
function safeImageId(value) {
const id = String(value == null ? '' : value);
if (!ASSET_RE.test(id)) throw new Error('会话图像标识无效');
return id;
}
function fileOf(imageId) {
return path.join(directory(), `${safeImageId(imageId)}.jpg`);
}
function verifyJpeg(bytes) {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
throw new Error('会话图像不是有效的 JPEG');
}
}
// 上一次写入被中断会留下 .tmp,'wx' 会因此永久失败,这里清掉再重试一次
function writeBlob(dest, bytes) {
const temp = `${dest}.tmp`;
try {
atomic.writeBytesExclusive(dest, bytes);
} catch (error) {
if (!error || error.code !== 'EEXIST' || !fs.existsSync(temp)) throw error;
fs.unlinkSync(temp);
atomic.writeBytesExclusive(dest, bytes);
}
}
function put(buffer, mimeType) {
if (String(mimeType == null ? '' : mimeType).toLowerCase() !== MIME_TYPE) {
throw new Error('会话图像仅支持 JPEG');
}
let bytes = null;
if (Buffer.isBuffer(buffer)) bytes = buffer;
else if (buffer instanceof Uint8Array) bytes = Buffer.from(buffer);
if (!bytes || !bytes.length) throw new Error('会话图像数据为空');
if (bytes.length > MAX_IMAGE_BYTES) throw new Error('会话图像超过 3 MB');
verifyJpeg(bytes);
const imageId = `img_${crypto.createHash('sha256').update(bytes).digest('hex')}`;
const dest = fileOf(imageId);
if (!fs.existsSync(dest)) {
if (totalBytes() + bytes.length > MAX_TOTAL_BYTES) throw new Error('会话图像总量已达上限');
writeBlob(dest, bytes);
}
return { imageId, bytes: bytes.length };
}
function read(imageId) {
const file = fileOf(imageId);
let stat = null;
try {
stat = fs.statSync(file);
} catch (error) {
throw new Error('会话图像不存在');
}
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) {
throw new Error('会话图像为空或超过 3 MB');
}
const bytes = fs.readFileSync(file);
verifyJpeg(bytes);
return bytes;
}
function dataUrl(imageId) {
return `data:${MIME_TYPE};base64,${read(imageId).toString('base64')}`;
}
function listBlobs() {
let names = [];
try {
names = fs.readdirSync(directory());
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
const blobs = [];
for (const name of names) {
const match = /^(img_[a-f0-9]{64})\.jpg$/.exec(name);
if (!match) continue;
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (error) { continue; }
if (!stat.isFile()) continue;
blobs.push({ imageId: match[1], file, size: stat.size, mtimeMs: stat.mtimeMs });
}
return blobs;
}
// 图像先落盘、再被会话引用,中间存在窗口期。
// 宽限期内的新文件一律不删,否则一次并发的清理就能抹掉正在提交的图像。
function cleanup(referencedIds, options) {
const opts = options && typeof options === 'object' ? options : {};
const graceMs = Number.isFinite(Number(opts.graceMs)) && Number(opts.graceMs) >= 0
? Number(opts.graceMs)
: GRACE_MS;
const keep = new Set();
for (const id of Array.from(referencedIds || [])) {
const text = String(id == null ? '' : id);
if (ASSET_RE.test(text)) keep.add(text);
}
const now = Date.now();
let removed = 0;
for (const blob of listBlobs()) {
if (keep.has(blob.imageId)) continue;
if (now - blob.mtimeMs < graceMs) continue;
try {
fs.unlinkSync(blob.file);
removed++;
} catch (error) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
function totalBytes() {
let total = 0;
for (const blob of listBlobs()) total += blob.size;
return total;
}
module.exports = {
init,
safeImageId,
put,
read,
dataUrl,
cleanup,
totalBytes,
MIME_TYPE,
MAX_IMAGE_BYTES,
MAX_TOTAL_BYTES,
GRACE_MS
};
+999
View File
@@ -0,0 +1,999 @@
// AI 多轮对话持久化:每个会话一个文件,index.json 只是可重建的派生缓存。
//
// 不并入 reader.json:那边每次写入都要 clone 整库快照并重写整个文件,
// 逐轮追加的对话会把最贵的数据放进最热的写路径。
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const images = require('./ai-images');
const VERSION = 1;
const GLOBAL_ENTRY_ID = 'system:global-chat';
const LIMITS = {
sessionId: 160,
title: 200,
messageText: 20000,
question: 4000,
contextText: 12000,
contextHash: 32,
errorText: 500,
documentKey: 500,
locatorJson: 50000,
messagesPerSession: 200,
sessionsTotal: 100,
sessionFileBytes: 4 * 1024 * 1024,
imagesPerSession: 8,
imageBytes: 3 * 1024 * 1024,
imageTotalBytes: 256 * 1024 * 1024
};
const SCOPES = new Set(['selection', 'page', 'document', 'page-image', 'region-image']);
const TASKS = new Set(['ask', 'translate', 'explain', 'summarize']);
const INDEX_NAME = 'index.json';
const DOC_CACHE_SIZE = 4;
const PENDING_LIMIT = 16;
const TITLE_CHARS = 40;
const MID_MARK = '\n[……中间内容已省略……]\n';
// 会话 ID 会被拼进文件名,所以比 store.js 的 isSafeId 更严:
// 不允许 '.' 与 ':',前者能拼出 .bak 之类的兄弟文件名,后者在 Windows 上会被当成 NTFS 数据流。
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
let rootDir = null;
let indexCache = null;
let docCache = new Map();
let pending = new Map();
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-ai-sessions');
indexCache = null;
docCache = new Map();
pending = new Map();
}
function directory() {
if (rootDir) return rootDir;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'reader-ai-sessions');
}
function indexFile() {
return path.join(directory(), INDEX_NAME);
}
function isObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function newId(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
function isReservedKey(value) {
return value === '__proto__' || value === 'prototype' || value === 'constructor';
}
function isSafeEntryId(value) {
return typeof value === 'string'
&& value.length > 0
&& value.length <= LIMITS.sessionId
&& /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)
&& value !== '.'
&& value !== '..'
&& !isReservedKey(value);
}
function normalizeEntryId(value) {
if (value == null || value === '') return GLOBAL_ENTRY_ID;
const id = String(value);
if (!isSafeEntryId(id)) throw new Error('会话条目 ID 无效');
return id;
}
function safeSessionId(value) {
const id = String(value == null ? '' : value);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) {
throw new Error('会话 ID 无效');
}
return id;
}
function safeMessageId(value) {
const id = String(value == null ? '' : value);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) {
throw new Error('会话消息 ID 无效');
}
return id;
}
function limitedString(value, max) {
return String(value == null ? '' : value).slice(0, max);
}
function nullableString(value, max, label) {
if (value == null || value === '') return null;
const result = String(value);
if (/[\u0000-\u001f]/.test(result)) throw new Error(`${label}无效`);
return result.slice(0, max);
}
function count(value, max) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.min(Math.floor(n), max);
}
function clampInt(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}
function timestamp(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
function jsonValue(value, label) {
if (value == null) return null;
let encoded;
try {
encoded = JSON.stringify(value);
} catch (e) {
throw new Error(`${label}必须可序列化`);
}
if (encoded === undefined || encoded.length > LIMITS.locatorJson) {
throw new Error(`${label}无效或过大`);
}
return JSON.parse(encoded);
}
function normalizeDocumentKey(value) {
const key = nullableString(value, LIMITS.documentKey, '文档标识');
if (key && isReservedKey(key)) throw new Error('文档标识无效');
return key;
}
function hashContext(text) {
return crypto.createHash('sha256')
.update(String(text == null ? '' : text), 'utf8')
.digest('hex')
.slice(0, LIMITS.contextHash);
}
function normalizeHash(value) {
if (value == null || value === '') return '';
const text = String(value);
if (!/^[0-9a-fA-F]+$/.test(text)) throw new Error('会话上下文摘要无效');
return text.toLowerCase().slice(0, LIMITS.contextHash);
}
function normalizeTask(value, lenient) {
if (value == null || value === '') return null;
const task = String(value);
if (!TASKS.has(task)) {
if (lenient) return null;
throw new Error('会话任务类型无效');
}
return task;
}
function normalizeImages(raw, lenient) {
if (raw == null) return [];
if (!Array.isArray(raw)) {
if (lenient) return [];
throw new Error('会话图像列表格式无效');
}
const result = [];
const seen = new Set();
for (const item of raw) {
try {
const value = isObject(item) ? item : {};
const imageId = images.safeImageId(value.imageId);
const mimeType = String(value.mimeType == null || value.mimeType === ''
? images.MIME_TYPE
: value.mimeType).toLowerCase();
if (mimeType !== images.MIME_TYPE) throw new Error('会话图像仅支持 JPEG');
const bytes = count(value.bytes, LIMITS.imageBytes + 1);
if (bytes > LIMITS.imageBytes) throw new Error('会话图像超过 3 MB');
if (seen.has(imageId)) continue;
seen.add(imageId);
result.push({
imageId,
mimeType,
width: count(value.width, 100000),
height: count(value.height, 100000),
bytes,
ocrIncluded: value.ocrIncluded === true
});
} catch (error) {
if (!lenient) throw error;
}
}
if (result.length > LIMITS.imagesPerSession) {
if (!lenient) throw new Error('单条消息图像过多');
result.length = LIMITS.imagesPerSession;
}
return result;
}
function normalizeContextRef(raw, lenient) {
if (raw == null) return null;
if (!isObject(raw)) {
if (lenient) return null;
throw new Error('会话上下文格式无效');
}
try {
const scope = String(raw.scope == null ? '' : raw.scope);
if (!SCOPES.has(scope)) throw new Error('会话上下文范围无效');
const source = raw.hash == null && raw.text != null ? String(raw.text) : null;
return {
scope,
chars: source != null && raw.chars == null ? source.length : count(raw.chars, 1e9),
hash: source != null ? hashContext(source) : normalizeHash(raw.hash),
clipped: raw.clipped === true,
locator: jsonValue(raw.locator, '定位信息'),
documentKey: normalizeDocumentKey(raw.documentKey),
fileIndex: raw.fileIndex == null ? null : count(raw.fileIndex, 100000)
};
} catch (error) {
if (lenient) return null;
throw error;
}
}
function buildMessage(raw, role, lenient) {
const value = isObject(raw) ? raw : {};
return {
id: newId('msg'),
role,
text: limitedString(value.text, role === 'user' ? LIMITS.question : LIMITS.messageText),
task: normalizeTask(value.task, lenient),
contextRef: role === 'user' ? normalizeContextRef(value.contextRef, lenient) : null,
images: normalizeImages(value.images, lenient),
tokensEstimate: count(value.tokensEstimate, 1e9),
truncated: value.truncated === true,
cancelled: role === 'assistant' && value.cancelled === true,
error: role === 'assistant'
? (lenient
? limitedString(value.error, LIMITS.errorText) || null
: nullableString(value.error, LIMITS.errorText, '会话错误信息'))
: null,
createdAt: Date.now()
};
}
function messageFromDisk(raw) {
const value = isObject(raw) ? raw : {};
const role = value.role === 'assistant' ? 'assistant' : (value.role === 'user' ? 'user' : null);
if (!role) return null;
const message = buildMessage(value, role, true);
let id = null;
try { id = safeMessageId(value.id); } catch (error) { id = null; }
message.id = id || message.id;
message.createdAt = timestamp(value.createdAt, message.createdAt);
return message;
}
function emptySession(id, entryId) {
const now = Date.now();
return {
version: VERSION,
id,
title: '',
entryId: entryId || GLOBAL_ENTRY_ID,
documentKey: null,
pinned: false,
droppedMessages: 0,
createdAt: now,
updatedAt: now,
messages: []
};
}
// 会话文件内容部分来自模型输出,读回时一律重新过一遍规范化,坏消息直接丢弃而不是抛错
function normalizeSession(raw, id) {
if (!isObject(raw)) throw new Error('会话文件结构无效');
const doc = emptySession(id, null);
let entryId = GLOBAL_ENTRY_ID;
try { entryId = normalizeEntryId(raw.entryId); } catch (error) { entryId = GLOBAL_ENTRY_ID; }
doc.entryId = entryId;
doc.title = limitedString(raw.title, LIMITS.title);
try { doc.documentKey = normalizeDocumentKey(raw.documentKey); } catch (error) { doc.documentKey = null; }
doc.pinned = raw.pinned === true;
doc.droppedMessages = count(raw.droppedMessages, 1e9);
doc.createdAt = timestamp(raw.createdAt, doc.createdAt);
doc.updatedAt = timestamp(raw.updatedAt, doc.createdAt);
const list = Array.isArray(raw.messages) ? raw.messages : [];
for (const item of list) {
const message = messageFromDisk(item);
if (message) doc.messages.push(message);
}
return doc;
}
function fileOf(sessionId) {
return path.join(directory(), `${safeSessionId(sessionId)}.json`);
}
function fileBytes(sessionId) {
try {
return fs.statSync(fileOf(sessionId)).size;
} catch (error) {
return 0;
}
}
function cacheGet(id, hash) {
const entry = docCache.get(id);
if (!entry || entry.hash !== hash) return null;
docCache.delete(id);
docCache.set(id, entry);
return entry.doc;
}
// 缓存按内容哈希失效而不是 mtime + size:时间戳粒度粗,
// 同一毫秒内的两次改写会得到相同的 mtime 与体积,按 stat 判定就会返回旧内容
function cacheSet(id, hash, doc) {
docCache.delete(id);
docCache.set(id, { hash, doc });
while (docCache.size > DOC_CACHE_SIZE) {
docCache.delete(docCache.keys().next().value);
}
}
function hashText(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function parseSessionText(text, id) {
const doc = normalizeSession(JSON.parse(text), id);
doc.id = id;
return doc;
}
function readSessionFile(file, id) {
const text = fs.readFileSync(file, 'utf8');
if (Buffer.byteLength(text, 'utf8') > LIMITS.sessionFileBytes * 2) {
throw new Error('会话文件过大');
}
const hash = hashText(text);
const cached = cacheGet(id, hash);
if (cached) return cached;
const doc = parseSessionText(text, id);
cacheSet(id, hash, doc);
return doc;
}
function readDoc(sessionId) {
const id = safeSessionId(sessionId);
const file = fileOf(id);
const backup = `${file}.bak`;
if (!fs.existsSync(file)) {
if (!fs.existsSync(backup)) return null;
try { fs.renameSync(backup, file); } catch (error) { return null; }
}
try {
return readSessionFile(file, id);
} catch (error) {
docCache.delete(id);
if (fs.existsSync(backup)) {
try {
const recovered = parseSessionText(fs.readFileSync(backup, 'utf8'), id);
try { fs.renameSync(file, `${file}.corrupt-${Date.now()}`); } catch (renameError) { /* ignore */ }
fs.copyFileSync(backup, file);
cacheSet(id, hashText(fs.readFileSync(file, 'utf8')), recovered);
return recovered;
} catch (backupError) { /* 下面隔离损坏文件 */ }
}
try { fs.renameSync(file, `${file}.corrupt-${Date.now()}`); } catch (renameError) { /* ignore */ }
const row = indexRow(id);
const doc = emptySession(id, row ? row.entryId : null);
if (row) doc.title = row.title;
writeDoc(doc);
return doc;
}
}
function requireDoc(sessionId) {
const doc = readDoc(sessionId);
if (!doc) throw new Error('会话不存在');
return doc;
}
function writeDoc(doc) {
const file = fileOf(doc.id);
let encoded = JSON.stringify(doc, null, 2);
while (Buffer.byteLength(encoded, 'utf8') > LIMITS.sessionFileBytes) {
if (!dropOldestPair(doc)) throw new Error('会话内容过大');
encoded = JSON.stringify(doc, null, 2);
}
atomic.writeJson(file, doc);
cacheSet(doc.id, hashText(encoded), doc);
putIndexRow(doc, Buffer.byteLength(encoded, 'utf8'));
}
function emptyIndex() {
return { version: VERSION, sessions: [] };
}
function normalizeIndexRow(raw) {
if (!isObject(raw)) return null;
let id;
let entryId;
try {
id = safeSessionId(raw.id);
entryId = normalizeEntryId(raw.entryId);
} catch (error) {
return null;
}
return {
id,
title: limitedString(raw.title, LIMITS.title),
entryId,
messageCount: count(raw.messageCount, LIMITS.messagesPerSession),
updatedAt: timestamp(raw.updatedAt, 0),
bytes: count(raw.bytes, Number.MAX_SAFE_INTEGER),
pinned: raw.pinned === true
};
}
function normalizeIndex(raw) {
if (!isObject(raw) || !Array.isArray(raw.sessions)) return null;
const sessions = [];
const seen = new Set();
for (const item of raw.sessions) {
const row = normalizeIndexRow(item);
if (!row || seen.has(row.id)) continue;
seen.add(row.id);
sessions.push(row);
}
return { version: VERSION, sessions };
}
function rowOf(doc, bytes) {
return {
id: doc.id,
title: doc.title,
entryId: doc.entryId,
messageCount: doc.messages.length,
updatedAt: doc.updatedAt,
bytes: bytes || 0,
pinned: !!doc.pinned
};
}
function saveIndex() {
atomic.writeJson(indexFile(), indexCache);
}
function loadIndex() {
if (indexCache) return indexCache;
const file = indexFile();
const backup = `${file}.bak`;
try {
if (!fs.existsSync(file) && fs.existsSync(backup)) fs.renameSync(backup, file);
const parsed = normalizeIndex(JSON.parse(fs.readFileSync(file, 'utf8')));
if (!parsed) throw new Error('会话索引结构无效');
indexCache = parsed;
return indexCache;
} catch (error) {
return rebuildIndex();
}
}
function indexRow(sessionId) {
const rows = loadIndex().sessions;
return rows.find((row) => row.id === sessionId) || null;
}
function putIndexRow(doc, bytes) {
const idx = loadIndex();
const row = rowOf(doc, bytes);
const at = idx.sessions.findIndex((item) => item.id === doc.id);
if (at < 0) idx.sessions.push(row);
else idx.sessions[at] = row;
saveIndex();
}
function dropIndexRow(sessionId) {
const idx = loadIndex();
const at = idx.sessions.findIndex((item) => item.id === sessionId);
if (at < 0) return false;
idx.sessions.splice(at, 1);
saveIndex();
return true;
}
function sessionFiles() {
let names = [];
try {
names = fs.readdirSync(directory());
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
const found = [];
for (const name of names) {
if (name === INDEX_NAME || !name.endsWith('.json')) continue;
const id = name.slice(0, -5);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) continue;
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (error) { continue; }
if (!stat.isFile()) continue;
found.push({ id, file, bytes: stat.size });
}
return found;
}
// 扫目录而不是读索引:索引是派生缓存,损坏时若按索引对账就会漏掉真实存在的会话
function scanSessions(includeBackups) {
const result = [];
for (const item of sessionFiles()) {
let doc = null;
try {
doc = readSessionFile(item.file, item.id);
} catch (error) {
doc = null;
}
if (doc) {
result.push({ id: item.id, bytes: item.bytes, doc });
if (!includeBackups) continue;
}
if (!includeBackups) continue;
const backup = `${item.file}.bak`;
if (!fs.existsSync(backup)) continue;
try {
result.push({ id: item.id, bytes: item.bytes, doc: parseSessionText(fs.readFileSync(backup, 'utf8'), item.id) });
} catch (error) { /* 备份也坏了就没有更多引用可救 */ }
}
return result;
}
function rebuildIndex() {
const sessions = [];
for (const item of scanSessions(false)) sessions.push(rowOf(item.doc, item.bytes));
sessions.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
indexCache = { version: VERSION, sessions };
if (fs.existsSync(directory())) {
try { saveIndex(); } catch (error) { /* 内存索引仍可用,下次再落盘 */ }
}
return indexCache;
}
function metaOf(doc, bytes) {
return {
id: doc.id,
title: doc.title,
entryId: doc.entryId,
documentKey: doc.documentKey,
pinned: !!doc.pinned,
droppedMessages: doc.droppedMessages,
messageCount: doc.messages.length,
bytes: bytes || 0,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt
};
}
function metaOfSession(sessionId) {
const doc = requireDoc(sessionId);
return metaOf(doc, fileBytes(doc.id));
}
function mutate(sessionId, fn) {
const id = safeSessionId(sessionId);
const doc = requireDoc(id);
let result;
try {
result = fn(doc);
} catch (error) {
docCache.delete(id);
throw error;
}
try {
writeDoc(doc);
} catch (error) {
docCache.delete(id);
throw error;
}
return result;
}
// 首轮承载正文,永不丢;其余成对丢弃,
// 否则会留下没有提问的孤立回答或连续两条 user,两者都会被 Anthropic 的 /messages 拒绝
function dropOldestPair(doc) {
const start = doc.messages[1] && doc.messages[1].role === 'assistant' ? 2 : 1;
if (doc.messages.length <= start) return false;
const n = doc.messages[start].role === 'user'
&& doc.messages[start + 1]
&& doc.messages[start + 1].role === 'assistant'
? 2
: 1;
doc.messages.splice(start, n);
doc.droppedMessages += n;
return true;
}
function countImages(doc) {
let total = 0;
for (const message of doc.messages) total += message.images.length;
return total;
}
function enforceLimits(doc) {
while (doc.messages.length > LIMITS.messagesPerSession) {
if (!dropOldestPair(doc)) break;
}
let total = countImages(doc);
while (total > LIMITS.imagesPerSession) {
const victim = doc.messages.find((message) => message.images.length > 0);
if (!victim) break;
total -= victim.images.length;
victim.images = [];
}
}
function normalizeTitle(value) {
return String(value == null ? '' : value)
.replace(/[\u0000-\u001f\s]+/g, ' ')
.trim()
.slice(0, LIMITS.title);
}
function autoTitle(text) {
return normalizeTitle(text).slice(0, TITLE_CHARS);
}
function list(filters) {
const value = isObject(filters) ? filters : {};
let rows = loadIndex().sessions;
if (value.entryId != null && value.entryId !== '') {
const entryId = normalizeEntryId(value.entryId);
rows = rows.filter((row) => row.entryId === entryId);
}
return clone(rows).sort((a, b) => (
(Number(b.pinned) - Number(a.pinned))
|| (b.updatedAt - a.updatedAt)
|| a.id.localeCompare(b.id)
));
}
function create(input) {
const value = isObject(input) ? input : {};
const entryId = normalizeEntryId(value.entryId);
const title = normalizeTitle(value.title);
const documentKey = normalizeDocumentKey(value.documentKey);
if (loadIndex().sessions.length >= LIMITS.sessionsTotal) {
throw new Error('会话数量已达上限,请先删除旧会话');
}
let id = newId('chat');
for (let attempt = 0; attempt < 5 && fs.existsSync(fileOf(id)); attempt++) id = newId('chat');
if (fs.existsSync(fileOf(id))) throw new Error('会话创建失败,请重试');
const doc = emptySession(id, entryId);
doc.title = title;
doc.documentKey = documentKey;
doc.pinned = value.pinned === true;
writeDoc(doc);
return metaOf(doc, fileBytes(id));
}
function rename(sessionId, title) {
mutate(sessionId, (doc) => {
doc.title = normalizeTitle(title);
doc.updatedAt = Date.now();
return true;
});
return metaOfSession(sessionId);
}
function setPinned(sessionId, pinned) {
mutate(sessionId, (doc) => {
doc.pinned = pinned === true;
doc.updatedAt = Date.now();
return true;
});
return metaOfSession(sessionId);
}
function remove(sessionId) {
const id = safeSessionId(sessionId);
const file = fileOf(id);
docCache.delete(id);
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === id) pending.delete(key);
}
let targets = [file, `${file}.tmp`, `${file}.bak`];
try {
const prefix = `${path.basename(file)}.corrupt-`;
targets = targets.concat(
fs.readdirSync(directory())
.filter((name) => name.startsWith(prefix))
.map((name) => path.join(directory(), name))
);
} catch (error) { /* 目录尚不存在 */ }
let removed = false;
for (const target of targets) {
try {
if (fs.existsSync(target)) {
fs.unlinkSync(target);
removed = true;
}
} catch (error) {
if (target === file) throw error;
}
}
if (dropIndexRow(id)) removed = true;
return removed;
}
function clear(sessionId) {
mutate(sessionId, (doc) => {
doc.messages = [];
doc.droppedMessages = 0;
doc.updatedAt = Date.now();
return true;
});
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === safeSessionId(sessionId)) pending.delete(key);
}
return metaOfSession(sessionId);
}
function messages(sessionId, options) {
const opts = isObject(options) ? options : {};
const id = safeSessionId(sessionId);
const doc = requireDoc(id);
const limit = clampInt(opts.limit, 1, LIMITS.messagesPerSession, LIMITS.messagesPerSession);
let end = doc.messages.length;
if (opts.before != null && opts.before !== '') {
const cursor = safeMessageId(opts.before);
const at = doc.messages.findIndex((message) => message.id === cursor);
if (at < 0) throw new Error('会话消息不存在');
end = at;
}
const start = Math.max(0, end - limit);
return {
meta: metaOf(doc, fileBytes(id)),
messages: clone(doc.messages.slice(start, end)),
hasMore: start > 0
};
}
function appendUser(sessionId, input) {
const value = isObject(input) ? input : {};
const message = buildMessage(value, 'user', false);
const stored = mutate(sessionId, (doc) => {
doc.messages.push(message);
if (!doc.title) doc.title = autoTitle(message.text);
if (!doc.documentKey && message.contextRef && message.contextRef.documentKey) {
doc.documentKey = message.contextRef.documentKey;
}
enforceLimits(doc);
doc.updatedAt = Date.now();
return message;
});
return clone(stored);
}
// 占位回答只留在内存里:流式过程中每个增量都落盘会把一轮对话放大成上百次整文件重写,
// 而空回答本身没有保存价值,进程意外退出丢掉它不损失用户数据。
function appendAssistant(sessionId, input) {
const id = safeSessionId(sessionId);
requireDoc(id);
const message = buildMessage(isObject(input) ? input : {}, 'assistant', false);
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === id) pending.delete(key);
}
while (pending.size >= PENDING_LIMIT) pending.delete(pending.keys().next().value);
pending.set(message.id, { sessionId: id, message });
return clone(message);
}
function finishAssistant(sessionId, messageId, patch) {
const id = safeSessionId(sessionId);
const msgId = safeMessageId(messageId);
const value = isObject(patch) ? patch : {};
const held = pending.get(msgId);
if (held && held.sessionId !== id) throw new Error('会话消息不存在');
const stored = mutate(id, (doc) => {
const message = held
? held.message
: doc.messages.find((item) => item.id === msgId && item.role === 'assistant');
if (!message) throw new Error('会话消息不存在');
message.text = limitedString(value.text == null ? message.text : value.text, LIMITS.messageText);
if (value.task !== undefined) message.task = normalizeTask(value.task, false);
if (value.images !== undefined) message.images = normalizeImages(value.images, false);
if (value.tokensEstimate !== undefined) message.tokensEstimate = count(value.tokensEstimate, 1e9);
message.cancelled = value.cancelled === true;
message.error = nullableString(value.error, LIMITS.errorText, '会话错误信息');
if (held) doc.messages.push(message);
enforceLimits(doc);
doc.updatedAt = Date.now();
return message;
});
pending.delete(msgId);
return clone(stored);
}
function clipMiddle(text, max) {
if (text.length <= max) return text;
if (max <= 0) return '';
if (max <= MID_MARK.length + 4) return text.slice(text.length - max);
const head = Math.ceil((max - MID_MARK.length) / 2);
const tail = max - MID_MARK.length - head;
return `${text.slice(0, head)}${MID_MARK}${text.slice(text.length - tail)}`;
}
function clipMessage(message, max) {
if (message.text.length <= max) return message;
message.text = clipMiddle(message.text, max);
message.truncated = true;
return message;
}
function normalizeBudget(budget) {
const value = isObject(budget) ? budget : {};
return {
maxChars: clampInt(value.maxChars, 50, 4000000, LIMITS.contextText),
maxMessages: clampInt(value.maxMessages, 1, LIMITS.messagesPerSession, 20),
maxMessageChars: clampInt(value.maxMessageChars, 50, LIMITS.messageText, LIMITS.question)
};
}
// Anthropic 的 /messages 要求首条是 user 且不允许连续同角色,
// 所以合并同角色、丢掉领头的 assistant 都不是可选优化,缺一条就是 400。
function mergeSameRole(kept) {
const merged = [];
for (const message of kept) {
const last = merged[merged.length - 1];
if (!last || last.role !== message.role) {
merged.push(message);
continue;
}
last.text = last.text && message.text ? `${last.text}\n\n${message.text}` : `${last.text}${message.text}`;
last.images = last.images.concat(message.images).slice(0, LIMITS.imagesPerSession);
last.truncated = last.truncated || message.truncated;
last.cancelled = message.cancelled;
last.error = message.error || last.error;
last.tokensEstimate = last.tokensEstimate + message.tokensEstimate;
}
return merged;
}
function enforceTotal(kept, maxChars) {
let total = kept.reduce((sum, message) => sum + message.text.length, 0);
for (const message of kept) {
if (total <= maxChars) break;
if (!message.text.length) continue;
const target = Math.max(0, message.text.length - (total - maxChars));
const next = clipMiddle(message.text, target);
total -= message.text.length - next.length;
message.text = next;
message.truncated = true;
}
}
function historyFor(sessionId, budget) {
const doc = requireDoc(sessionId);
const limits = normalizeBudget(budget);
const all = clone(doc.messages);
const carried = doc.droppedMessages;
if (!all.length) return { messages: [], dropped: carried };
const keep = new Set();
let used = 0;
let slots = limits.maxMessages;
const pinIndex = all.findIndex((message) => message.role === 'user');
if (pinIndex >= 0) {
all[pinIndex] = clipMessage(all[pinIndex], limits.maxMessageChars);
keep.add(pinIndex);
used += all[pinIndex].text.length;
slots -= 1;
}
for (let i = all.length - 1; i >= 0 && slots > 0; i--) {
if (keep.has(i)) continue;
const message = clipMessage(all[i], limits.maxMessageChars);
if (used + message.text.length > limits.maxChars) break;
keep.add(i);
used += message.text.length;
slots -= 1;
}
let kept = all.filter((message, index) => keep.has(index));
let dropped = all.length - kept.length;
while (kept.length && kept[0].role === 'assistant') {
kept.shift();
dropped++;
}
kept = mergeSameRole(kept);
const total = dropped + carried;
const mark = total > 0 && kept.length
? `[……已省略较早的 ${Math.max(1, Math.ceil(total / 2))} 轮对话……]\n`
: '';
enforceTotal(kept, Math.max(0, limits.maxChars - mark.length));
if (mark) {
kept[0].text = `${mark}${kept[0].text}`;
kept[0].truncated = true;
}
return { messages: kept, dropped: total };
}
// GC 的 keep 集合来自这里,因此必须扫全部会话文件(含 .bak):
// 只读索引的话,索引损坏时正在被引用的图会被当成垃圾删掉,属于静默数据丢失。
function imageIds() {
const ids = new Set();
for (const item of scanSessions(true)) {
for (const message of item.doc.messages) {
for (const image of message.images) ids.add(image.imageId);
}
}
for (const item of pending.values()) {
for (const image of item.message.images) ids.add(image.imageId);
}
return Array.from(ids);
}
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const orphans = [];
for (const item of scanSessions(false)) {
const doc = item.doc;
if (doc.entryId === GLOBAL_ENTRY_ID) continue;
if (known.has(doc.entryId)) continue;
orphans.push({
sessionId: doc.id,
entryId: doc.entryId,
title: doc.title,
messageCount: doc.messages.length,
bytes: item.bytes
});
}
orphans.sort((a, b) => b.bytes - a.bytes || a.sessionId.localeCompare(b.sessionId));
return orphans;
}
function forgetMany(entryIds) {
const ids = new Set();
for (const value of Array.isArray(entryIds) ? entryIds : []) {
const id = normalizeEntryId(value);
if (id === GLOBAL_ENTRY_ID) continue;
ids.add(id);
}
if (!ids.size) return 0;
let removed = 0;
for (const item of scanSessions(false)) {
if (!ids.has(item.doc.entryId)) continue;
try {
if (remove(item.doc.id)) removed++;
} catch (error) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
module.exports = {
init,
list,
create,
rename,
setPinned,
remove,
clear,
messages,
appendUser,
appendAssistant,
finishAssistant,
historyFor,
imageIds,
orphanReport,
forgetMany,
rebuildIndex,
hashContext,
GLOBAL_ENTRY_ID,
LIMITS,
VERSION
};
+92 -23
View File
@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
const MAX_OBJECTS = 5000;
@@ -11,10 +12,12 @@ const DOCUMENT_SAMPLE_BYTES = 4 * 1024 * 1024;
let rootDir = null;
let documentKeys = new Map();
let countCache = new Map();
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-annotations');
documentKeys = new Map();
countCache = new Map();
}
function directory() {
@@ -136,29 +139,7 @@ function read(entryId) {
}
function write(entryId, data) {
const dest = fileOf(entryId);
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(directory(), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(data, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响使用 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次读取时恢复 */ }
throw e;
}
atomic.writeJson(fileOf(entryId), data);
}
function get(entryId, documentKey) {
@@ -205,8 +186,93 @@ function setPage(entryId, documentKey, page, pageData) {
return { page: Number(pageKey), count: clean.objects.length, updatedAt: doc.updatedAt };
}
function countObjects(data) {
let total = 0;
for (const doc of Object.values(data.documents || {})) {
if (!doc || typeof doc !== 'object' || !doc.pages || typeof doc.pages !== 'object') continue;
for (const page of Object.values(doc.pages)) {
if (page && Array.isArray(page.objects)) total += page.objects.length;
}
}
return total;
}
// 批注文件单个可达 64 MB,而书库每次刷新都要取一遍计数,
// 按 mtime + size 缓存避免重复解析未改动的文件
function getCounts() {
const counts = {};
let names = [];
try {
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
} catch (e) {
return counts;
}
const seen = new Set();
for (const name of names) {
const entryId = name.slice(0, -5);
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
seen.add(entryId);
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (e) { continue; }
const cached = countCache.get(entryId);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
if (cached.count > 0) counts[entryId] = cached.count;
continue;
}
let count = 0;
try {
count = countObjects(parseDocument(file, entryId));
} catch (e) {
count = 0;
}
countCache.set(entryId, { mtimeMs: stat.mtimeMs, size: stat.size, count });
if (count > 0) counts[entryId] = count;
}
for (const key of Array.from(countCache.keys())) {
if (!seen.has(key)) countCache.delete(key);
}
return counts;
}
// 批注没有独立的浏览界面,条目一旦离开书库就再也看不到,
// 因此对账时连体积一起报出来,便于用户判断是否回收
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const counts = getCounts();
const orphans = [];
let names = [];
try {
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
} catch (e) {
return orphans;
}
for (const name of names) {
const entryId = name.slice(0, -5);
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
if (known.has(entryId)) continue;
let size = 0;
try { size = fs.statSync(path.join(directory(), name)).size; } catch (e) { continue; }
orphans.push({ entryId, count: counts[entryId] || 0, bytes: size });
}
orphans.sort((a, b) => b.bytes - a.bytes || a.entryId.localeCompare(b.entryId));
return orphans;
}
function forgetMany(entryIds) {
const ids = Array.isArray(entryIds) ? entryIds : [];
let removed = 0;
for (const id of ids) {
try {
if (forget(id)) removed++;
} catch (e) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
function forget(entryId) {
const file = fileOf(entryId);
countCache.delete(normalizeEntryId(entryId));
let removed = false;
let targets = [file, `${file}.tmp`, `${file}.bak`];
try {
@@ -236,6 +302,9 @@ module.exports = {
hashDocumentFile,
get,
setPage,
getCounts,
orphanReport,
forgetMany,
forget,
LARGE_DOCUMENT_BYTES,
DOCUMENT_SAMPLE_BYTES
+250
View File
@@ -0,0 +1,250 @@
// 笔记独立窗口的生命周期管理。
// 单窗口多标签,形态与阅读器一致:窗口只有一个,一条笔记占一个标签。
//
// 「一标签一条」是数据安全约束,不是体验优化:`reader:updateNote` 是整条覆盖、
// 无版本校验,同一条笔记开两个编辑器时后保存者会把前者的内容整块吃掉。
//
// openNotes 是主进程侧的标签集镜像,由渲染层通过 `notes:tabsChanged` 上报。
// 它同时承担授权职责(见 ownsNote),所以不能只信渲染层:新增标签一律先过
// main.js 的 findNote() 用 listNotes() 对账。
const path = require('path');
const { pathToFileURL } = require('url');
const { BrowserWindow } = require('electron');
const CLOSE_TIMEOUT = 10000;
let win = null;
// noteId -> entryId。删除对账要按 entryId 找标签,所以存的是映射不是集合。
const openNotes = new Map();
let onChanged = null;
let closeAllowed = false;
let closePending = false;
let closeTimer = null;
function alive(target) {
return !!target && !target.isDestroyed();
}
function keyOf(noteId) {
return String(noteId == null ? '' : noteId);
}
function get() {
if (alive(win)) return win;
win = null;
return null;
}
function openIds() {
if (!get()) return [];
return [...openNotes.keys()];
}
function notifyChanged() {
if (typeof onChanged === 'function') onChanged(openIds());
}
function setChangeListener(fn) {
onChanged = typeof fn === 'function' ? fn : null;
}
function pageUrl(rootDir) {
return pathToFileURL(path.join(rootDir, 'src', 'ui', 'note.html')).href;
}
function isNoteSender(wc) {
const target = get();
return !!target && !!wc && target.webContents.id === wc.id;
}
function fromWebContents(wc) {
return isNoteSender(wc) ? 'note' : null;
}
// 笔记窗口只能读自己已经打开的那些标签。放宽成「只要是笔记窗口就给」
// 会让这个通道变成遍历全部笔记的后门。
function ownsNote(wc, noteId) {
if (!isNoteSender(wc)) return false;
return openNotes.has(keyOf(noteId));
}
// 渲染层只能"收窄"标签集(上报自己关掉了哪些),不能新增。
// 允许新增等于让渲染层自己扩权:谎报持有某条笔记,随后 notes:getOne 就放行了。
// 新增只能走 open(),那条路径在 main.js 里过 findNote() 对账。
function setTabs(wc, noteIds) {
if (!isNoteSender(wc)) return false;
const claimed = new Set();
for (const item of Array.isArray(noteIds) ? noteIds : []) {
const id = keyOf(item && item.noteId != null ? item.noteId : item);
if (id) claimed.add(id);
}
let changed = false;
for (const key of [...openNotes.keys()]) {
if (claimed.has(key)) continue;
openNotes.delete(key);
changed = true;
}
if (changed) notifyChanged();
return true;
}
function sendToWindow(channel, payload) {
const target = get();
if (!target) return false;
target.webContents.send(channel, payload);
return true;
}
function create(rootDir, uiTheme) {
closeAllowed = false;
closePending = false;
win = new BrowserWindow({
width: 1080,
height: 820,
minWidth: 720,
minHeight: 520,
frame: false,
backgroundColor: '#141414',
icon: path.join(
rootDir,
'icons',
'dist',
uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico'
),
title: 'PeopleLib',
webPreferences: {
preload: path.join(rootDir, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
});
const created = win;
created.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
created.webContents.on('will-navigate', (event, url) => {
if (!String(url).startsWith(pageUrl(rootDir))) event.preventDefault();
});
// 未保存的编辑要在窗口消失之前问用户,所以必须先拦下 close 交给渲染层。
// 渲染层卡住时靠看门狗兜底,否则窗口永远关不掉。
created.on('close', (event) => {
if (closeAllowed || !alive(created)) return;
event.preventDefault();
if (closePending) return;
closePending = true;
created.webContents.send('notes:prepareClose', null);
closeTimer = setTimeout(() => {
if (alive(created)) {
closeAllowed = true;
created.destroy();
}
}, CLOSE_TIMEOUT);
});
created.on('closed', () => {
if (win === created) win = null;
openNotes.clear();
closeAllowed = false;
closePending = false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
notifyChanged();
});
return created;
}
function open(entryId, noteId, rootDir, uiTheme = 'dark') {
const key = keyOf(noteId);
const entry = String(entryId);
const existing = get();
if (existing) {
if (existing.isMinimized()) existing.restore();
existing.focus();
// 已经开着的标签由渲染层激活,不新建第二个编辑器
existing.webContents.send('notes:openTab', { entryId: entry, noteId: key });
if (!openNotes.has(key)) {
openNotes.set(key, entry);
notifyChanged();
}
return existing;
}
const created = create(rootDir, uiTheme);
openNotes.set(key, entry);
created.loadFile(path.join(rootDir, 'src', 'ui', 'note.html'), {
query: { entryId: entry, noteId: key }
});
notifyChanged();
return created;
}
// 笔记在别处被删除后标签必须自己退场,否则它下一次保存会把已删条目整条写回去
function closeFor(noteId) {
const key = keyOf(noteId);
if (!get() || !openNotes.has(key)) return false;
sendToWindow('notes:closeTab', { noteIds: [key] });
openNotes.delete(key);
notifyChanged();
return true;
}
function closeMany(noteIds) {
const keys = (Array.isArray(noteIds) ? noteIds : [])
.map((id) => keyOf(id))
.filter((id) => openNotes.has(id));
if (!get() || !keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
function closeForEntries(entryIds) {
const targets = new Set((Array.isArray(entryIds) ? entryIds : []).map((id) => String(id)));
if (!get() || !targets.size) return 0;
const keys = [];
for (const [key, entryId] of openNotes) {
if (targets.has(entryId)) keys.push(key);
}
if (!keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
// 渲染层处理完未保存提示后才真正放行关闭
function shutdownReady(wc) {
const target = get();
if (!target || !isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closeAllowed = true;
closePending = false;
target.close();
return true;
}
// 用户在未保存提示里选了取消。必须复位 closePending,否则下一次点关闭会被
// 「已在处理中」挡掉,窗口再也关不上;也必须撤掉看门狗,否则十秒后它会把
// 带着未保存内容的窗口直接销毁。
function cancelClose(wc) {
if (!isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closePending = false;
return true;
}
function all() {
const target = get();
return target ? [target] : [];
}
module.exports = {
open, get, all, openIds, closeFor, closeMany, closeForEntries,
fromWebContents, ownsNote, setTabs, shutdownReady, cancelClose, setChangeListener
};
+43 -19
View File
@@ -4,6 +4,7 @@
const fs = require('fs');
const path = require('path');
const atomic = require('../atomic-file');
const VERSION = 6;
const STANDALONE_ENTRY_ID = 'system:standalone-notes';
@@ -792,24 +793,7 @@ function migrate(raw) {
}
function save() {
const dest = getFilePath();
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) { fs.renameSync(dest, backup); backedUp = true; }
fs.renameSync(temp, dest);
if (backedUp) { try { fs.unlinkSync(backup); } catch (e) { /* ignore */ } }
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次 load 时恢复 */ }
throw e;
}
atomic.writeJson(getFilePath(), cache);
}
function load() {
@@ -1345,6 +1329,46 @@ function removeCollection(collectionId) {
});
}
// 与书库对账:列出书库里已不存在的条目。这些阅读资料在「我的笔记」里仍然可见,
// 属于有意保留,因此只报告不自动删除,由用户显式决定。
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const c = load();
const orphans = [];
for (const [entryId, entry] of Object.entries(c.entries)) {
if (entryId === STANDALONE_ENTRY_ID) continue;
if (known.has(entryId)) continue;
const notes = Array.isArray(entry.notes) ? entry.notes.length : 0;
const bookmarks = Array.isArray(entry.bookmarks) ? entry.bookmarks.length : 0;
const snapshot = entry.book || {};
orphans.push({
entryId,
title: snapshot.title || '',
notes,
bookmarks,
hasProgress: !!entry.progress
});
}
orphans.sort((a, b) => b.notes - a.notes || a.entryId.localeCompare(b.entryId));
return orphans;
}
function forgetMany(entryIds) {
const ids = (Array.isArray(entryIds) ? entryIds : []).map((id) => safeId(id, '条目 ID'));
if (!ids.length) return 0;
let removed = 0;
mutateCache((current) => {
for (const id of ids) {
if (id === STANDALONE_ENTRY_ID) continue;
if (!Object.prototype.hasOwnProperty.call(current.entries, id)) continue;
delete current.entries[id];
removed++;
}
return removed > 0;
});
return removed;
}
// forget 是显式删除:只清掉指定条目的阅读数据,不影响其它条目或笔记本。
function forget(value) {
const id = safeId(value, '条目 ID');
@@ -1363,5 +1387,5 @@ module.exports = {
addNote, addStandaloneNote, updateNote, removeNote, listNotes, getNoteCounts,
noteAssetIds,
listCollections, addCollection, updateCollection, removeCollection,
forget
orphanReport, forgetMany, forget
};
+2 -23
View File
@@ -2,6 +2,7 @@
const fs = require('fs');
const path = require('path');
const atomic = require('./atomic-file');
let filePath = null;
let cache = null;
@@ -29,29 +30,7 @@ function load() {
}
function save() {
const dest = getFilePath();
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响提交 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (e) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollbackError) { /* 下次加载时会恢复 */ }
throw e;
}
atomic.writeJson(getFilePath(), cache);
}
function get(key, def) {
+71
View File
@@ -219,6 +219,77 @@ $('semanticKeyClearBtn').onclick = async () => {
refreshSemanticKeyStatus();
let orphanFound = null;
function describeOrphans(data) {
const notes = data.notes || [];
const annotations = data.annotations || [];
if (!notes.length && !annotations.length) return '没有残留数据';
const parts = [];
if (notes.length) {
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
parts.push(`${notes.length} 本已移除书籍留有阅读资料(含 ${noteTotal} 条笔记)`);
}
if (annotations.length) {
const mb = (data.totalBytes || 0) / 1024 / 1024;
const size = mb >= 0.1 ? `${mb.toFixed(1)} MB` : `${Math.round((data.totalBytes || 0) / 1024)} KB`;
parts.push(`${annotations.length} 份孤立批注(约 ${size}`);
}
return parts.join('');
}
$('orphanScanBtn').onclick = async () => {
const btn = $('orphanScanBtn');
btn.disabled = true;
btn.textContent = '检查中...';
const r = await window.api.reader.orphanReport();
btn.disabled = false;
btn.textContent = '检查';
if (!r || !r.ok) {
$('orphanStatus').textContent = `检查失败:${(r && r.error) || '未知错误'}`;
return;
}
orphanFound = r.data;
$('orphanStatus').textContent = describeOrphans(r.data);
const hasAny = (r.data.notes || []).length > 0 || (r.data.annotations || []).length > 0;
$('orphanPurgeBtn').classList.toggle('hidden', !hasAny);
};
$('orphanPurgeBtn').onclick = async () => {
if (!orphanFound) return;
const notes = orphanFound.notes || [];
const annotations = orphanFound.annotations || [];
// 笔记是用户创作,删除不可撤销,必须让用户单独确认这一项
const lines = [];
if (annotations.length) lines.push(`<li>${annotations.length} 份孤立批注</li>`);
if (notes.length) {
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
lines.push(`<li>${notes.length} 本已移除书籍的阅读资料,含 <b>${noteTotal} 条笔记</b></li>`);
}
const choice = await openModal('清理残留阅读资料', `
<p>检查到:</p>
<ul class="library-bulk-preview">${lines.join('')}</ul>
<p style="margin-top:8px"><label><input type="checkbox" id="orphanDelAnnotations" checked /> 清理孤立批注</label></p>
${notes.length ? `<p style="margin-top:6px"><label><input type="checkbox" id="orphanDelNotes" /> 同时删除笔记、书签与进度</label></p>
<p class="muted" style="margin-top:6px">这些笔记目前仍可在「我的笔记」中查看,删除后无法恢复。</p>` : ''}
`, () => ({
annotations: !!(document.getElementById('orphanDelAnnotations') || {}).checked,
notes: !!(document.getElementById('orphanDelNotes') || {}).checked
}));
if (!choice) return;
if (!choice.annotations && !choice.notes) return;
const r = await window.api.reader.purgeOrphans(choice);
if (!r || !r.ok) {
await confirmModal('清理失败', (r && r.error) || '未知错误');
return;
}
const done = r.data || {};
$('orphanStatus').textContent =
`已清理 ${done.annotationsRemoved || 0} 份批注、${done.notesRemoved || 0} 本阅读资料`;
$('orphanPurgeBtn').classList.add('hidden');
orphanFound = null;
};
const AI_PROTOCOL_INFO = {
anthropic: {
label: 'Anthropic',
+13 -2
View File
@@ -214,6 +214,17 @@
<button id="proxySaveBtn" class="tb-btn">保存</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">残留阅读资料</div>
<div class="settings-item-desc">书籍移除时默认保留笔记、批注与进度。笔记仍可在「我的笔记」中查看,批注则没有查看入口。</div>
<div class="settings-path" id="orphanStatus">未检查</div>
</div>
<button id="orphanScanBtn" class="tb-btn">检查</button>
<button id="orphanPurgeBtn" class="tb-btn danger hidden">清理</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
@@ -302,11 +313,11 @@
<div class="about-formats">
<div>
<span class="about-format-label">内置阅读</span>
<span>PDF、EPUB、MOBI、AZW、AZW3</span>
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、MD</span>
</div>
<div>
<span class="about-format-label">书库导入与管理</span>
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、DJVU、FB2、CBZ、CBR</span>
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、MD、DJVU、FB2、CBZ、CBR</span>
</div>
<div class="settings-item-desc">
MOBI、AZW 与 AZW3 由 Foliate 解析,支持无 DRM 的 MOBI/KF7/KF8 内容;DRM、KFX 与损坏文件可改用系统应用打开。
+265
View File
@@ -0,0 +1,265 @@
/* 笔记独立窗口。整窗给编辑区,画布不再受模态尺寸限制。 */
.note-window-body {
display: flex;
height: 100vh;
flex-direction: column;
overflow: hidden;
}
/* 标签条与阅读器同构。note.html 不加载 reader.css,所以这里要自带一份,
class 名保持一致,改样式时两处要一起改。 */
.doctabs {
height: 36px;
display: flex;
align-items: stretch;
flex-shrink: 0;
padding: 0 6px;
background: var(--bg-soft);
border-bottom: 1px solid var(--line);
}
.doctabs-list {
display: flex;
align-items: stretch;
flex: 1;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
}
.doctabs-list::-webkit-scrollbar { height: 0; }
.doctab {
display: flex;
align-items: center;
max-width: 220px;
flex-shrink: 0;
margin: 4px 0;
padding: 0 8px 0 14px;
gap: 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
}
/* --hover-bg-soft 只在 reader.css 里定义,这里必须用 style.css 有的变量,
否则 hover 背景静默失效 */
.doctab:hover { background: var(--hover-bg); color: var(--text); }
.doctab.active { background: var(--bg-card); border-color: var(--line); color: var(--text); }
.doctab-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doctab.active .doctab-name { color: var(--accent-bright); font-weight: 600; }
.doctab-fmt {
padding: 0 5px;
flex-shrink: 0;
border: 1px solid var(--line);
border-radius: 5px;
color: var(--text-dim);
font-size: 10px;
}
/* 未保存标记:标签上必须看得见,否则关窗时才发现有改动 */
.doctab-dirty {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 50%;
background: var(--accent-bright);
}
.doctab-close {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 5px;
color: var(--text-dim);
font-size: 11px;
cursor: pointer;
}
.doctab-close:hover { background: var(--danger); color: #fff; }
/* 每个标签一个视图,非活跃的整块隐藏。用 display:none 而不是移出 DOM
编辑器实例要留着,切回来不必重建,也不会丢未保存内容。 */
.note-tab-views {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-tab-view {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-tab-view.inactive { display: none; }
.note-dirty-box { width: 420px; max-width: 92vw; }
.note-dirty-notice {
margin: 0 0 4px;
color: var(--text-dim);
font-size: 13px;
line-height: 1.6;
}
.note-window {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
padding: 14px 18px 16px;
gap: 10px;
}
.note-window-loading,
.note-window-error {
padding: 24px;
color: var(--text-dim);
text-align: center;
}
.note-window-error {
color: var(--danger);
}
.note-window-form {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 10px;
}
.note-window-head {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 10px;
}
.note-window-title {
height: 34px;
padding: 0 10px;
flex: 1;
min-width: 0;
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 9px;
font-size: 14px;
}
.note-window-title:focus {
border-color: var(--accent);
outline: none;
}
.note-window-badge {
padding: 4px 10px;
flex: 0 0 auto;
background: var(--hover-strong);
color: var(--text-dim);
border-radius: 999px;
font-size: 12px;
}
/* 编辑区吃满剩余空间。min-height:0 缺一层,flex 子项就会被内容顶高、画布溢出窗口 */
.note-window-editor {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-window-editor > .mixed-note-editor,
.note-window-editor .mixed-note-canvas,
.note-window-editor .mixed-note-text,
.note-window-editor .rich-note-editor {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-window-editor .ql-container {
min-height: 0;
flex: 1;
}
.note-window-quote {
max-height: 88px;
margin: 0;
padding: 8px 10px;
flex: 0 0 auto;
overflow-y: auto;
background: var(--hover);
color: var(--text-dim);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
font-size: 12px;
}
.note-window-meta {
display: flex;
align-items: center;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 12px;
}
.note-window-meta label {
display: flex;
align-items: center;
min-width: 0;
color: var(--text-dim);
font-size: 12px;
gap: 6px;
}
/* 笔记本名过长会把整行顶宽,须同时限宽并允许收缩到 0 */
.note-window-meta select,
.note-window-meta input[type="text"] {
height: 28px;
max-width: 260px;
min-width: 0;
text-overflow: ellipsis;
padding: 0 8px;
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
font-size: 12px;
}
.note-window-meta select:focus,
.note-window-meta input[type="text"]:focus {
border-color: var(--accent);
outline: none;
}
.note-window-tags input { width: 220px; }
.note-window-pin { cursor: pointer; }
.note-window-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 12px;
}
.note-window-status {
flex: 1;
color: var(--text-dim);
font-size: 12px;
}
.note-window-status.error { color: var(--danger); }
@media (max-width: 720px) {
.note-window { padding: 10px 12px 12px; }
.note-window-tags input { width: 140px; }
}
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="zh-CN" data-ui-theme="dark">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; script-src 'self'" />
<title>PeopleLib</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="vendor/quill/quill.snow.css" />
<link rel="stylesheet" href="rich-note.css" />
<link rel="stylesheet" href="note-window.css" />
</head>
<body class="note-window-body">
<div class="titlebar">
<div class="titlebar-left">
<span class="brand">
<img class="brand-logo brand-logo-dark" src="../../icons/dist/dark/icon-32.png" alt="" />
<img class="brand-logo brand-logo-light" src="../../icons/dist/light/icon-32.png" alt="" />
<span>笔记</span>
</span>
</div>
<div class="titlebar-spacer"></div>
<div class="titlebar-controls">
<button id="uiThemeBtn" class="win-btn ui-theme-btn" title="切换到明亮主题" aria-label="切换到明亮主题">
<svg class="titlebar-icon ui-theme-sun" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
</svg>
<svg class="titlebar-icon ui-theme-moon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/>
</svg>
</button>
<button id="minBtn" class="win-btn" title="最小化">&#9472;</button>
<button id="maxBtn" class="win-btn" title="最大化">&#9633;</button>
<button id="closeBtn" class="win-btn win-close" title="关闭">&#10005;</button>
</div>
</div>
<div class="doctabs">
<div id="noteTabsList" class="doctabs-list" role="tablist"></div>
</div>
<main class="note-window">
<div id="noteWindowLoading" class="note-window-loading">正在加载笔记…</div>
<div id="noteWindowError" class="note-window-error hidden" role="alert"></div>
<div id="noteWindowEmpty" class="note-window-loading hidden">没有打开的笔记</div>
<div id="noteTabViews" class="note-tab-views"></div>
</main>
<template id="noteTabTemplate">
<form class="note-window-form" autocomplete="off">
<div class="note-window-head">
<input class="note-window-title" type="text" maxlength="300" placeholder="标题(可选)" />
<span class="note-window-badge"></span>
</div>
<div class="note-window-editor"></div>
<blockquote class="note-window-quote hidden"></blockquote>
<div class="note-window-meta">
<label>
<span>笔记本</span>
<select class="note-window-collection">
<option value="">未分类</option>
</select>
</label>
<label class="note-window-tags">
<span>标签</span>
<input class="note-window-tags-input" type="text" placeholder="用逗号分隔" />
</label>
<label class="note-window-pin">
<input class="note-window-pinned" type="checkbox" /> 置顶
</label>
</div>
<div class="note-window-actions">
<span class="note-window-status" role="status"></span>
<button class="tb-btn note-window-save" type="submit">保存笔记</button>
</div>
</form>
</template>
<div id="noteDirtyModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="noteDirtyTitle">
<div class="modal-box note-dirty-box">
<div id="noteDirtyTitle" class="modal-title">这条笔记还没保存?</div>
<p id="noteDirtyNotice" class="note-dirty-notice">关闭后未保存的修改会丢失。</p>
<div class="modal-actions">
<button id="noteDirtyCancelBtn" class="tb-btn ghost" type="button">取消</button>
<button id="noteDirtyDiscardBtn" class="tb-btn danger" type="button">放弃修改</button>
<button id="noteDirtySaveBtn" class="tb-btn" type="button">保存并关闭</button>
</div>
</div>
</div>
<script src="vendor/jszip.min.js"></script>
<script src="vendor/quill/quill.js"></script>
<script src="vendor/jspdf.umd.min.js"></script>
<script src="vendor/purify.min.js"></script>
<script src="rich-note.js"></script>
<script src="mixed-note.js"></script>
<script type="module" src="views/note-shell.js"></script>
</body>
</html>
+34
View File
@@ -468,6 +468,15 @@ input[type="color"],
padding: 6px 8px; word-break: break-word; flex-shrink: 0;
}
.ai-status.warn { color: var(--warn-text); border-color: var(--warn-line); }
.ai-sessions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.ai-session-label { font-size: 11px; color: var(--text-dim); flex: none; }
.ai-session-select {
flex: 1; min-width: 0;
background: var(--bg-card); border: 1px solid var(--line); color: var(--text);
border-radius: 6px; padding: 3px 6px; font-size: 11px;
}
.ai-session-actions { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.ai-session-actions .tb-btn { flex: 1 1 auto; }
.ai-scope { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.ai-scope-label { font-size: 11px; color: var(--text-dim); flex: none; }
.ai-scope-select {
@@ -511,6 +520,31 @@ input[type="color"],
.ai-output.streaming { border-color: var(--accent); }
.ai-output > :first-child { margin-top: 0; }
.ai-output > :last-child { margin-bottom: 0; }
.ai-thread { display: flex; flex-direction: column; gap: 10px; }
.ai-thread-notice {
padding: 5px 8px; border: 1px dashed var(--line); border-radius: 7px;
color: var(--text-dim); font-size: 11px; line-height: 1.6;
}
.ai-msg { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.ai-msg-meta {
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
color: var(--text-dim); font-size: 11px; line-height: 1.5;
}
.ai-msg-context { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-msg-body {
padding: 7px 10px; border: 1px solid var(--line); border-radius: 9px;
background: var(--input-bg); overflow-wrap: anywhere;
}
.ai-msg-body > :first-child { margin-top: 0; }
.ai-msg-body > :last-child { margin-bottom: 0; }
.ai-msg-body.ai-output-plain { white-space: pre-wrap; }
.ai-msg-user .ai-msg-body {
border-left: 2px solid var(--accent); background: var(--accent-faint); white-space: pre-wrap;
}
.ai-msg-assistant.ai-msg-streaming .ai-msg-body { border-color: var(--accent); }
.ai-msg-error .ai-msg-body { border-color: var(--danger); color: var(--error-text); }
.ai-msg-flags { color: var(--text-dim); font-size: 11px; line-height: 1.6; }
.ai-msg-actions { display: flex; flex-wrap: wrap; gap: 5px; }
.ai-output p,
.ai-output ul,
.ai-output ol,
+54 -3
View File
@@ -142,6 +142,17 @@
<div id="pane-ai" class="pane-body hidden ai-pane">
<div id="aiStatus" class="ai-status">正在读取模型配置…</div>
<div class="ai-sessions">
<span class="ai-session-label">会话</span>
<select id="aiSessionSelect" class="ai-session-select" title="切换当前书籍的对话会话"></select>
</div>
<div class="ai-session-actions">
<button id="aiSessionNewBtn" class="tb-btn ghost sm" title="新建一个会话,发送首个问题后才会创建">新建</button>
<button id="aiSessionRenameBtn" class="tb-btn ghost sm" title="重命名当前会话">重命名</button>
<button id="aiSessionPinBtn" class="tb-btn ghost sm" title="置顶当前会话">置顶</button>
<button id="aiSessionClearBtn" class="tb-btn ghost sm" title="清空当前会话的全部消息">清空</button>
<button id="aiSessionDeleteBtn" class="tb-btn danger sm" title="删除当前会话">删除</button>
</div>
<div class="ai-scope">
<span class="ai-scope-label">上下文</span>
<select id="aiScope" class="ai-scope-select" title="决定每次提问发送多少正文,范围越大消耗越多">
@@ -170,12 +181,12 @@
<button class="tb-btn ghost sm" data-ai-task="summarize">总结</button>
</div>
<div id="aiQuote" class="ai-quote hidden"></div>
<div id="aiOutput" class="ai-output" aria-live="polite"></div>
<div id="aiOutput" class="ai-output ai-thread" aria-live="polite"></div>
<div id="aiError" class="ai-error hidden"></div>
<div class="ai-out-actions">
<button id="aiStopBtn" class="tb-btn danger sm hidden">停止生成</button>
<button id="aiSaveBtn" class="tb-btn sm hidden">保存为笔记</button>
<button id="aiCopyBtn" class="tb-btn ghost sm hidden">复制</button>
<button id="aiSaveBtn" class="tb-btn sm hidden" title="把最后一条回答保存为笔记">保存为笔记</button>
<button id="aiCopyBtn" class="tb-btn ghost sm hidden" title="复制最后一条回答">复制</button>
</div>
<div class="ai-input">
<textarea id="aiQuestion" rows="3" maxlength="4000" placeholder="基于当前章节内容提问…"></textarea>
@@ -217,6 +228,13 @@
<option value="auto">自动</option>
</select>
</label>
<label><span>画质</span>
<select id="pdfRenderQuality" class="mini-select" title="PDF 渲染画质,越高越清晰也越占显存">
<option value="1">标准</option>
<option value="2">清晰</option>
<option value="3">极清</option>
</select>
</label>
</div>
<select id="themeSelect" class="mini-select" title="阅读主题">
<option value="light">浅色</option>
@@ -271,6 +289,39 @@
</div>
</div>
<div id="aiSessionRenameModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="aiSessionRenameTitle">
<div class="modal-box confirm-box">
<div id="aiSessionRenameTitle" class="modal-title">重命名会话</div>
<input id="aiSessionTitleInput" class="note-editor-input" type="text" maxlength="120" placeholder="会话标题" />
<div class="modal-actions">
<button id="aiSessionRenameCancelBtn" class="tb-btn ghost">取消</button>
<button id="aiSessionRenameSaveBtn" class="tb-btn">保存标题</button>
</div>
</div>
</div>
<div id="aiSessionDeleteModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="aiSessionDeleteTitle">
<div class="modal-box confirm-box">
<div id="aiSessionDeleteTitle" class="modal-title">删除这个会话?</div>
<p id="aiSessionDeleteNotice" class="ai-confirm-notice">删除后这个会话里的全部对话记录会从本地移除,无法恢复。</p>
<div class="modal-actions">
<button id="aiSessionDeleteCancelBtn" class="tb-btn ghost">取消</button>
<button id="aiSessionDeleteConfirmBtn" class="tb-btn danger">删除会话</button>
</div>
</div>
</div>
<div id="aiSessionClearModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="aiSessionClearTitle">
<div class="modal-box confirm-box">
<div id="aiSessionClearTitle" class="modal-title">清空这个会话的消息?</div>
<p class="ai-confirm-notice">会话本身会保留,但里面的全部对话记录会从本地移除,无法恢复。</p>
<div class="modal-actions">
<button id="aiSessionClearCancelBtn" class="tb-btn ghost">取消</button>
<button id="aiSessionClearConfirmBtn" class="tb-btn danger">清空消息</button>
</div>
</div>
</div>
<div id="annotationClearModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="annotationClearTitle">
<div class="modal-box confirm-box">
<div id="annotationClearTitle" class="modal-title">清空当前页批注?</div>
+73 -7
View File
@@ -20,6 +20,38 @@ const PDF_ASSET_OPTIONS = Object.freeze({
wasmUrl: new URL('../vendor/pdfjs/wasm/', import.meta.url).href
});
const MAX_CANVAS_SIDE = 16384;
const MAX_CANVAS_AREA = 268435456;
export function clampCanvasSize(width, height, quality) {
const cssWidth = Number.isFinite(Number(width)) && Number(width) > 0 ? Number(width) : 1;
const cssHeight = Number.isFinite(Number(height)) && Number(height) > 0 ? Number(height) : 1;
const wanted = Number(quality);
const want = Number.isFinite(wanted) && wanted > 0 ? wanted : 1;
const bySide = MAX_CANVAS_SIDE / Math.max(cssWidth, cssHeight);
const byArea = Math.sqrt(MAX_CANVAS_AREA / (cssWidth * cssHeight));
const applied = Math.min(want, bySide, byArea);
const canvasWidth = Math.max(1, Math.floor(cssWidth * applied));
const canvasHeight = Math.max(1, Math.floor(cssHeight * applied));
// 超出浏览器画布上限的尺寸会静默产出不可用画布(整页空白且不报错),
// 只能宁可降清晰度也要把尺寸压回上限内
return {
width: canvasWidth,
height: canvasHeight,
quality: applied,
// 取整后 backing 与 CSS 盒子的比例不再等于名义倍率,render 的 transform 必须用这两个实际比例
scaleX: canvasWidth / cssWidth,
scaleY: canvasHeight / cssHeight,
clamped: applied < want
};
}
export function clampRenderQuality(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 1;
return Math.max(1, Math.min(4, n));
}
const CSS = `
.pdfx-scroller{--pdfx-page-gap:16px;--pdfx-page-padding:16px;position:absolute;inset:0;overflow:auto;background:#f3f3f3}
.pdfx-pages{display:grid;grid-template-columns:max-content;grid-auto-flow:row;grid-auto-columns:max-content;align-items:start;justify-content:safe center;gap:var(--pdfx-page-gap);min-width:100%;padding:var(--pdfx-page-padding) 0;box-sizing:border-box}
@@ -206,6 +238,8 @@ export function createPdfAdapter() {
const annotationHistory = new Map();
let scale = 1.2;
let renderQuality = 1;
let lastRenderStats = null;
let theme = 'light';
let viewMode = 'continuous';
let pageLayout = 'single';
@@ -628,16 +662,20 @@ export function createPdfAdapter() {
setBox(p, vp.width, vp.height);
const dpr = window.devicePixelRatio || 1;
p.canvas.width = Math.max(1, Math.floor(vp.width * dpr));
p.canvas.height = Math.max(1, Math.floor(vp.height * dpr));
const wanted = Math.max(dpr, clampRenderQuality(renderQuality));
const fit = clampCanvasSize(vp.width, vp.height, wanted);
p.canvas.width = fit.width;
p.canvas.height = fit.height;
lastRenderStats = { dpr, wanted, fit };
const ctx = p.canvas.getContext('2d', { alpha: false });
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, p.canvas.width, p.canvas.height);
const identity = fit.scaleX === 1 && fit.scaleY === 1;
const task = pdfPage.render({
canvasContext: ctx,
viewport: vp,
transform: dpr === 1 ? null : [dpr, 0, 0, dpr, 0, 0]
transform: identity ? null : [fit.scaleX, 0, 0, fit.scaleY, 0, 0]
});
p.task = task;
try {
@@ -1028,16 +1066,23 @@ export function createPdfAdapter() {
if (!container) throw new Error('缺少渲染容器');
const nextScale = clampScale(opts && opts.scale);
const nextTheme = (opts && opts.theme) || theme;
const nextQuality = opts && opts.renderQuality !== undefined
? clampRenderQuality(opts.renderQuality)
: renderQuality;
if (host !== container || !scroller || !scroller.isConnected) {
teardownView();
scale = nextScale;
renderQuality = nextQuality;
mount(container);
applyTheme(nextTheme);
} else {
if (nextScale !== scale) {
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
if (nextScale !== scale || nextQuality !== renderQuality) {
epoch++;
scale = nextScale;
renderQuality = nextQuality;
lastRenderStats = null;
for (const p of pages) {
recycle(p);
applyPlaceholder(p);
@@ -1196,11 +1241,14 @@ export function createPdfAdapter() {
const baseViewport = pdfPage.getViewport({ scale: 1 });
const area = normalizeCrop(crop, baseViewport.width, baseViewport.height);
// 直接渲染到最终发送尺寸:先渲染到 2048 再压回上限会多做一次重采样,反而把文字磨糊
const renderScale = Math.max(1, Math.min(4, MAX_CAPTURE_DIMENSION / Math.max(area.width, area.height)));
const wantScale = Math.max(1, Math.min(4, MAX_CAPTURE_DIMENSION / Math.max(area.width, area.height)));
// wantScale 下限是 1,大幅面页的截图画布就等于页面点尺寸,MediaBox 异常的文件仍会顶到画布上限
const fit = clampCanvasSize(area.width, area.height, wantScale);
const renderScale = fit.quality;
const viewport = pdfPage.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(area.width * renderScale));
canvas.height = Math.max(1, Math.round(area.height * renderScale));
canvas.width = fit.width;
canvas.height = fit.height;
const context = canvas.getContext('2d', { alpha: false });
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
@@ -1304,6 +1352,7 @@ export function createPdfAdapter() {
}
for (const p of pages) recycle(p);
pages.length = 0;
lastRenderStats = null;
if (scroller && scroller.parentNode) scroller.parentNode.removeChild(scroller);
if (host) host.textContent = '';
scroller = null;
@@ -1369,6 +1418,23 @@ export function createPdfAdapter() {
return pageLayout;
},
fitWidthScale,
renderStats() {
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
const wanted = lastRenderStats ? lastRenderStats.wanted : Math.max(dpr, clampRenderQuality(renderQuality));
const fit = lastRenderStats
? lastRenderStats.fit
: clampCanvasSize(baseSize.width * scale, baseSize.height * scale, wanted);
return {
renderQuality: clampRenderQuality(renderQuality),
dpr: lastRenderStats ? lastRenderStats.dpr : dpr,
requestedScale: wanted,
effectiveScale: fit.quality,
clamped: fit.clamped,
canvasWidth: fit.width,
canvasHeight: fit.height,
pages: pageCount
};
},
setAnnotations,
setAnnotationTool,
setAnnotationStyle,
+804 -71
View File
File diff suppressed because it is too large Load Diff
+754
View File
@@ -0,0 +1,754 @@
// TXT / Markdown 阅读适配器:解码 → 切章 → 净化 → 打包成内存 EPUB,渲染交给 epub 适配器。
// 自己渲染要重写选区、字符偏移定位、滚动进度、主题注入一整套逻辑,mobi 适配器已经证明
// 转 EPUB 复用是更小的面;顺带把「整份文本塞进 DOM」变成按章加载,几十 MB 的 txt 也不会卡死。
import { createEpubAdapter } from './epub-adapter.mjs';
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
export const MAX_TEXT_BYTES = 64 * 1024 * 1024;
export const MARKDOWN_MAX_CHARS = 4 * 1024 * 1024;
const CHAPTER_TARGET_CHARS = 6000;
const CHAPTER_MAX_CHARS = 24000;
const MAX_CHAPTERS = 4000;
const PARA_MAX_CHARS = 800;
const MIN_DETECTED_HEADINGS = 3;
const HEADING_LINE_MAX = 60;
const MD_SPLIT_LEVEL = 2;
const MAX_TOC_ANCHORS = 2000;
const LABEL_MAX = 80;
const NAMED_HEADING = /^(?:序章|序言|自序|序|楔子|前言|引言|导言|導言|后记|後記|后序|尾声|尾聲|结语|結語|结尾|附录|附錄|番外|外传|外傳|致谢|致謝|目录|目錄)(?:[\s::、.-].{0,40})?$/;
const NUMBERED_HEADING = /^第\s*[0-90-9零〇一二三四五六七八九十百千万两]{1,12}\s*[章節节回卷篇部集话話幕折](?:[\s::、.-].{0,40})?$/;
const LATIN_HEADING = /^(?:chapter|part|book|section|act|episode)\s+(?:\d{1,4}|[ivxlcdm]{1,8})(?:[\s:.-].{0,40})?$/i;
const PLAIN_CSS = 'p.txtx-para{margin:0;white-space:pre-wrap;text-align:justify}'
+ 'p.txtx-head{font-weight:700}';
const MARKDOWN_CSS = 'h1,h2,h3,h4,h5,h6{margin:1.2em 0 .6em;line-height:1.35}'
+ 'h1{font-size:1.6em}h2{font-size:1.35em}h3{font-size:1.18em}'
+ 'p{margin:0 0 .8em}'
+ 'ul,ol{margin:0 0 .8em;padding-inline-start:1.6em}'
+ 'li{margin:.2em 0}'
+ 'blockquote{margin:0 0 .8em;padding-inline-start:.9em;border-inline-start:3px solid currentColor;opacity:.85}'
+ 'pre{margin:0 0 .8em;padding:.6em .8em;border:1px solid currentColor;border-radius:4px;white-space:pre-wrap}'
+ 'pre,code,kbd,samp{font-family:Consolas,"Courier New","Sarasa Mono SC",monospace}'
+ 'code{font-size:.92em}'
+ 'table{border-collapse:collapse;margin:0 0 .8em}'
+ 'th,td{border:1px solid currentColor;padding:.3em .6em}'
+ 'hr{border:0;border-top:1px solid currentColor;opacity:.5;margin:1.4em 0}'
+ '.txtx-md-image{opacity:.7;font-style:italic}';
const ALLOWED_TAGS = Object.freeze([
'p', 'br', 'hr', 'strong', 'em', 's', 'del', 'ins', 'mark', 'sub', 'sup',
'blockquote', 'pre', 'code', 'kbd', 'samp',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
'span'
]);
// 放行名单里没有 a/img,也没有 href/src:正文 iframe 是空 sandbox 的不透明来源,
// 任何导航只会把正文冲掉,外链必须经主进程;所以链接降级成纯文字、图片换成占位符。
const ALLOWED_ATTR = Object.freeze(['class', 'title', 'colspan', 'rowspan', 'start']);
function clamp(n, lo, hi) {
const v = Number(n);
if (!Number.isFinite(v)) return lo;
return Math.min(hi, Math.max(lo, v));
}
function xmlText(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function xmlAttr(value) {
return xmlText(value).replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function label(value, fallback) {
const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
if (!text) return fallback;
return text.length > LABEL_MAX ? `${text.slice(0, LABEL_MAX)}` : text;
}
export function bytesView(bytes) {
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
if (ArrayBuffer.isView(bytes)) return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (Array.isArray(bytes)) return new Uint8Array(bytes);
throw new Error('文本文件字节无效');
}
// XML 1.0 不接受 C0 控制符。留着会让生成的 XHTML 解析失败,正文随即退回容错的
// HTML 解析路径,行为难以预测;这些字符本来也不可见。U+FFFD 是合法 XML 字符且是
// 解码失败的唯一可见线索,必须留着。
function stripUnprintable(value) {
return String(value)
.replace(/\r\n?/g, '\n')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\ufffe\uffff]/g, '');
}
function decodeWith(encoding, data, fatal) {
try {
return new TextDecoder(encoding, { fatal: !!fatal }).decode(data);
} catch (e) {
return null;
}
}
// 只给「一定是正文」的字符加分,只给「一定不是正文」的字符扣分,其余非 ASCII 记零。
// 拉丁扩展区与通用标点必须记零:UTF-8 中文按 windows-1252 解出的乱码全落在那里,
// 一个汉字摊成三个乱码字符,给正分的话乱码反而赢过正确解码。
export function textScore(value) {
let good = 0;
let bad = 0;
const sample = String(value == null ? '' : value);
const capped = sample.length > 8192 ? sample.slice(0, 8192) : sample;
for (const ch of capped) {
const c = ch.codePointAt(0);
if (c === 0xfffd) { bad += 6; continue; }
if (c < 0x20) { if (c === 9 || c === 10) good += 1; else bad += 6; continue; }
if (c < 0x7f) { good += 1; continue; }
if (c <= 0x9f) { bad += 6; continue; }
if (c >= 0x4e00 && c <= 0x9fff) { good += 2; continue; }
if (c >= 0x3000 && c <= 0x303f) { good += 2; continue; }
if (c >= 0xff00 && c <= 0xffef) { good += 2; continue; }
if (c >= 0x3040 && c <= 0x30ff) { good += 2; continue; }
if (c >= 0xac00 && c <= 0xd7a3) { good += 2; continue; }
}
return good - bad;
}
// 无 BOM 的 UTF-16 在 Chromium 里没有内建嗅探,只能自己看 NUL 分布:
// UTF-16LE 存 ASCII 时高位字节恒为 0,落在奇数下标。
function guessUtf16(data) {
const limit = Math.min(data.byteLength - (data.byteLength % 2), 4096);
if (limit < 16) return '';
let odd = 0;
let even = 0;
for (let i = 0; i < limit; i++) {
if (data[i] !== 0) continue;
if (i % 2) odd++; else even++;
}
if ((odd + even) * 4 < limit) return '';
if (odd > even * 3) return 'utf-16le';
if (even > odd * 3) return 'utf-16be';
return '';
}
export function decodeTextBytes(bytes) {
const data = bytesView(bytes);
if (data.byteLength > MAX_TEXT_BYTES) {
throw new Error('该文本文件超过 64 MB,暂不支持在内置阅读器中打开,请使用外部应用');
}
const finish = (text, encoding, confident) => ({
text: stripUnprintable(String(text).replace(/^\ufeff+/, '')),
encoding,
confident
});
if (!data.byteLength) return finish('', 'utf-8', true);
if (data[0] === 0xff && data[1] === 0xfe) {
const text = decodeWith('utf-16le', data.subarray(2), false);
if (text != null) return finish(text, 'utf-16le', true);
}
if (data[0] === 0xfe && data[1] === 0xff) {
const text = decodeWith('utf-16be', data.subarray(2), false);
if (text != null) return finish(text, 'utf-16be', true);
}
if (data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
const text = decodeWith('utf-8', data.subarray(3), false);
if (text != null) return finish(text, 'utf-8', true);
}
// UTF-16 嗅探必须排在 UTF-8 之前:NUL 是合法 UTF-8 字节,无 BOM 的 UTF-16
// 能被严格 UTF-8 解码器全盘接受,正文会变成夹满空洞的乱码。
const utf16 = guessUtf16(data);
if (utf16) {
const text = decodeWith(utf16, data, false);
if (text != null) return finish(text, utf16, true);
}
// 严格 UTF-8 通过就直接采信:多字节序列的自校验很强,GBK/Big5 正文几乎不可能
// 恰好构成合法 UTF-8,再拿去和其他候选比分反而会被乱码的字符数优势翻盘。
const strict = decodeWith('utf-8', data, true);
if (strict != null) return finish(strict, 'utf-8', true);
// 同分时靠顺序决定,简体中文是本应用的主场,gb18030 排最前。
let best = null;
for (const encoding of ['gb18030', 'big5', 'euc-jp', 'euc-kr', 'windows-1252']) {
const text = decodeWith(encoding, data, false);
if (text == null) continue;
const score = textScore(text);
if (!best || score > best.score) best = { text, encoding, score };
}
if (best && best.score > 0) return finish(best.text, best.encoding, true);
// 所有候选都打不出正分:按 UTF-8 宽松解码保住可读部分,confident=false 交给上层提示,
// 不让用户对着乱码以为是文件坏了。
const loose = decodeWith('utf-8', data, false);
return finish(loose == null ? '' : loose, best ? best.encoding : 'utf-8', false);
}
export function lineChunks(value) {
const text = String(value == null ? '' : value);
const out = [];
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') { out.push(text.slice(start, i + 1)); start = i + 1; }
}
if (start < text.length) out.push(text.slice(start));
return out;
}
function lineOffsets(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') starts.push(i + 1);
}
return starts;
}
function headingLabel(line) {
const trimmed = line.replace(/[\s\u3000]+/g, ' ').trim();
if (!trimmed || trimmed.length > HEADING_LINE_MAX) return '';
if (NUMBERED_HEADING.test(trimmed) || NAMED_HEADING.test(trimmed) || LATIN_HEADING.test(trimmed)) return trimmed;
return '';
}
// 切出来的片段必须能原样拼回整章,textOf('document') 的「不缺不重」全靠这条。
export function splitParagraphs(value, limit) {
const lines = lineChunks(value);
const cap = Math.max(200, Number(limit) || PARA_MAX_CHARS);
const out = [];
let buffer = '';
for (let i = 0; i < lines.length; i++) {
buffer += lines[i];
const blank = !lines[i].trim();
const nextBlank = i + 1 < lines.length ? !lines[i + 1].trim() : true;
if (buffer.length >= cap || (blank && !nextBlank)) { out.push(buffer); buffer = ''; }
}
if (buffer) out.push(buffer);
return out;
}
function cutLongSpan(text, start, end, cap, sink, base) {
let from = start;
while (end - from > cap) {
let cut = text.lastIndexOf('\n', from + cap);
if (cut <= from) cut = from + cap - 1;
sink.push({ start: from, end: cut + 1, label: base, continued: from !== start });
from = cut + 1;
}
sink.push({ start: from, end, label: base, continued: from !== start });
}
function thinCuts(cuts, maxChapters) {
if (cuts.length <= maxChapters) return cuts;
const step = Math.ceil(cuts.length / maxChapters);
return cuts.filter((_, index) => index % step === 0);
}
function piecesToChapters(text, cuts, cap, maxChapters) {
const kept = thinCuts(cuts, maxChapters);
const pieces = [];
for (let i = 0; i < kept.length; i++) {
const end = i + 1 < kept.length ? kept[i + 1].start : text.length;
cutLongSpan(text, kept[i].start, end, cap, pieces, kept[i].label);
}
return pieces.map((piece) => ({
label: piece.continued ? `${piece.label}(续)` : piece.label,
text: text.slice(piece.start, piece.end),
start: piece.start,
end: piece.end,
continued: piece.continued
}));
}
export function splitPlainText(text, limits = {}) {
const source = String(text == null ? '' : text);
const maxChapters = Math.max(1, Number(limits.maxChapters) || MAX_CHAPTERS);
if (!source) {
return {
chapters: [{ label: '正文', text: '', start: 0, end: 0, continued: false }],
headings: [],
title: ''
};
}
const target = Math.max(
Number(limits.target) || CHAPTER_TARGET_CHARS,
Math.ceil(source.length / maxChapters)
);
const cap = Math.max(Number(limits.max) || CHAPTER_MAX_CHARS, target * 2);
const starts = lineOffsets(source);
const headings = [];
for (let i = 0; i < starts.length; i++) {
const end = i + 1 < starts.length ? starts[i + 1] : source.length;
const text2 = headingLabel(source.slice(starts[i], end));
if (text2) headings.push({ level: 1, label: text2, offset: starts[i] });
}
const cuts = [];
const anchored = headings.length >= MIN_DETECTED_HEADINGS;
if (anchored) {
if (headings[0].offset > 0) cuts.push({ start: 0, label: '开头' });
for (const heading of headings) cuts.push({ start: heading.offset, label: heading.label });
} else {
let index = 0;
let from = 0;
while (from < source.length) {
let cut = source.indexOf('\n', from + target);
if (cut < 0 || cut + 1 >= source.length) cut = source.length - 1;
cuts.push({ start: from, label: `${++index}` });
from = cut + 1;
}
if (!cuts.length) cuts.push({ start: 0, label: '第 1 节' });
}
// 很多 txt 小说首行是书名,可以当标题;但首行本身就是章节标题时不能拿来当书名。
const firstLine = lineChunks(source).find((line) => line.trim()) || '';
const usable = !headingLabel(firstLine) && firstLine.trim().length <= HEADING_LINE_MAX;
const titleLine = usable ? firstLine : '';
return {
chapters: piecesToChapters(source, cuts, cap, maxChapters),
headings: anchored ? headings : [],
title: label(titleLine, '')
};
}
export function markdownHeadings(source, md) {
const text = String(source == null ? '' : source);
if (!text.trim() || !md || typeof md.parse !== 'function') return [];
const starts = lineOffsets(text);
const tokens = md.parse(text, {});
const out = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type !== 'heading_open') continue;
const level = clamp(Number(String(token.tag || 'h1').slice(1)), 1, 6);
const line = token.map && Number.isInteger(token.map[0]) ? token.map[0] : 0;
const inline = tokens[i + 1];
out.push({
level,
label: label(inline && inline.content, `标题 ${out.length + 1}`),
offset: starts[clamp(line, 0, starts.length - 1)]
});
}
return out;
}
export function splitMarkdown(source, md, limits = {}) {
const text = String(source == null ? '' : source);
const maxChapters = Math.max(1, Number(limits.maxChapters) || MAX_CHAPTERS);
const headings = markdownHeadings(text, md);
const splitLevel = clamp(limits.splitLevel == null ? MD_SPLIT_LEVEL : limits.splitLevel, 1, 6);
const tops = headings.filter((h) => h.level <= splitLevel);
if (!tops.length) {
const plain = splitPlainText(text, limits);
return {
chapters: plain.chapters,
headings,
title: label(headings.length ? headings[0].label : plain.title, ''),
splitLevel
};
}
const target = Math.max(
Number(limits.target) || CHAPTER_TARGET_CHARS,
Math.ceil(text.length / maxChapters)
);
const cap = Math.max(Number(limits.max) || CHAPTER_MAX_CHARS, target * 2);
const cuts = [];
if (tops[0].offset > 0) cuts.push({ start: 0, label: '开头' });
for (const top of tops) cuts.push({ start: top.offset, label: top.label });
const topTitle = headings.find((h) => h.level === 1) || tops[0];
return {
chapters: piecesToChapters(text, cuts, cap, maxChapters),
headings,
title: label(topTitle.label, ''),
splitLevel
};
}
// 返回目录条目,同时给出每章按文档顺序排列的锚点 id,供渲染时打到对应标题元素上。
export function buildTocEntries(chapters, headings, splitLevel) {
const list = Array.isArray(headings) ? headings : [];
const anchorsByChapter = new Map();
if (!list.length) {
return {
entries: chapters.map((chapter, index) => ({
label: chapter.label,
depth: chapter.continued ? 1 : 0,
chapter: index,
anchor: ''
})),
anchorsByChapter
};
}
const entries = [];
let anchors = 0;
for (let i = 0; i < list.length; i++) {
const heading = list[i];
let owner = chapters.findIndex((c) => heading.offset >= c.start && heading.offset < c.end);
if (owner < 0) owner = chapters.length - 1;
if (owner < 0) continue;
const leading = heading.offset === chapters[owner].start;
const anchor = !leading && anchors < MAX_TOC_ANCHORS ? `txtx-h-${i}` : '';
if (anchor) anchors++;
if (!anchorsByChapter.has(owner)) anchorsByChapter.set(owner, []);
anchorsByChapter.get(owner).push(anchor);
entries.push({
label: heading.label,
depth: Math.max(0, heading.level - 1),
chapter: owner,
anchor
});
}
const covered = new Set(entries.map((entry) => entry.chapter));
const extra = [];
chapters.forEach((chapter, index) => {
if (covered.has(index)) return;
extra.push({
label: chapter.label,
depth: chapter.continued ? Number(splitLevel) || 1 : 0,
chapter: index,
anchor: ''
});
});
return {
entries: entries.concat(extra).sort((a, b) => a.chapter - b.chapter),
anchorsByChapter
};
}
export function navMarkup(entries) {
const list = Array.isArray(entries) ? entries : [];
let out = '<ol>';
let depth = 0;
let hasItem = false;
for (const entry of list) {
let want = Math.max(0, Math.min(Number(entry.depth) || 0, depth + 1));
if (want > depth && !hasItem) want = depth;
while (depth > want) { out += '</li></ol>'; depth--; hasItem = true; }
if (want > depth) { out += '<ol>'; depth++; hasItem = false; }
else if (hasItem) { out += '</li>'; hasItem = false; }
out += `<li><a href="${xmlAttr(entry.href)}">${xmlText(entry.label)}</a>`;
hasItem = true;
}
while (depth > 0) { out += '</li></ol>'; depth--; hasItem = true; }
if (hasItem) out += '</li>';
return `${out}</ol>`;
}
export function plainChapterXhtml(chapter) {
const value = chapter && typeof chapter === 'object' ? chapter : {};
const text = String(value.text == null ? '' : value.text);
const heading = !value.continued && headingLabel(lineChunks(text)[0] || '');
let head = '';
let rest = text;
if (heading) {
const first = lineChunks(text)[0] || '';
head = `<p class="txtx-para txtx-head">${xmlText(first)}</p>`;
rest = text.slice(first.length);
}
const body = splitParagraphs(rest, PARA_MAX_CHARS)
.map((piece) => `<p class="txtx-para">${xmlText(piece)}</p>`)
.join('');
return `<html xmlns="${XHTML_NS}"><head><title>${xmlText(value.label || '正文')}</title></head>`
+ `<body><style>${PLAIN_CSS}</style>${head}${body}</body></html>`;
}
export function createMarkdownRenderer(markdownit) {
if (typeof markdownit !== 'function') throw new Error('缺少 markdown-it 依赖,无法渲染 Markdown');
const md = markdownit({
// html:false 是第一道防线,正文里的裸 HTML 直接转义成文字;DOMPurify 是第二道。
// 两道都必须在,动任何一道之前先确认另一道单独够用。
html: false,
xhtmlOut: true,
breaks: false,
linkify: false,
typographer: false
});
md.renderer.rules.image = (tokens, index) => {
const alt = String(tokens[index].content || '').replace(/\s+/g, ' ').trim();
return `<span class="txtx-md-image">[${xmlText(alt ? `图片:${alt}` : '图片已省略')}]</span>`;
};
return md;
}
export function sanitizeMarkdownFragment(html, services) {
const purifier = services && services.purifier;
if (!purifier || typeof purifier.sanitize !== 'function') {
throw new Error('缺少 DOMPurify 依赖,无法安全渲染 Markdown');
}
return purifier.sanitize(String(html == null ? '' : html), {
ALLOWED_TAGS: [...ALLOWED_TAGS],
ALLOWED_ATTR: [...ALLOWED_ATTR],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
RETURN_DOM_FRAGMENT: true
});
}
export function markdownChapterXhtml(chapter, services, md, anchorIds) {
const value = chapter && typeof chapter === 'object' ? chapter : {};
const doc = services.document.implementation.createDocument(XHTML_NS, 'html', null);
const head = doc.createElementNS(XHTML_NS, 'head');
const titleEl = doc.createElementNS(XHTML_NS, 'title');
titleEl.appendChild(doc.createTextNode(String(value.label || '正文')));
head.appendChild(titleEl);
const body = doc.createElementNS(XHTML_NS, 'body');
doc.documentElement.appendChild(head);
doc.documentElement.appendChild(body);
const styleEl = doc.createElementNS(XHTML_NS, 'style');
styleEl.appendChild(doc.createTextNode(MARKDOWN_CSS));
body.appendChild(styleEl);
body.appendChild(doc.importNode(
sanitizeMarkdownFragment(md.render(String(value.text == null ? '' : value.text)), services),
true
));
const headings = Array.from(body.querySelectorAll('h1,h2,h3,h4,h5,h6'));
const ids = Array.isArray(anchorIds) ? anchorIds : [];
// 数量对不上说明章节边界切进了代码块之类的结构,此时按序号打 id 会指到错误的标题,
// 宁可放弃锚点(目录退化为跳到章首)。
if (ids.length === headings.length) {
headings.forEach((el, index) => { if (ids[index]) el.setAttribute('id', ids[index]); });
}
return new services.XMLSerializer().serializeToString(doc);
}
export function resolveServices() {
const scope = typeof window === 'undefined' ? {} : window;
return {
markdownit: scope.markdownit,
purifier: scope.DOMPurify,
document: scope.document,
XMLSerializer: scope.XMLSerializer,
JSZip: scope.JSZip
};
}
export async function buildTextEpub(bytes, options = {}) {
const services = resolveServices();
if (!services.JSZip) throw new Error('缺少 jszip 依赖,无法准备文本内容');
const report = typeof options.onProgress === 'function'
? (value) => { try { options.onProgress(clamp(value, 0, 1)); } catch (e) { /* ignore */ } }
: () => {};
const decoded = decodeTextBytes(bytes);
report(0.05);
const wantMarkdown = normalizeTextFormat(options.format) === 'md';
// 超大 Markdown 单文件降级为纯文本:markdown-it 是整篇一次性解析,
// 几 MB 以上的解析开销与内存都不可控,而这种体量的 .md 基本是日志或导出的数据。
const markdown = wantMarkdown && decoded.text.length <= MARKDOWN_MAX_CHARS;
if (markdown && (!services.document || !services.XMLSerializer)) {
throw new Error('渲染环境不完整,无法渲染 Markdown');
}
const md = markdown ? createMarkdownRenderer(services.markdownit) : null;
const split = markdown
? splitMarkdown(decoded.text, md, options.limits)
: splitPlainText(decoded.text, options.limits);
const chapters = split.chapters;
const { entries, anchorsByChapter } = buildTocEntries(
chapters,
split.headings,
markdown ? split.splitLevel : 1
);
report(0.1);
const zip = new services.JSZip();
for (let index = 0; index < chapters.length; index++) {
const xhtml = markdown
? markdownChapterXhtml(chapters[index], services, md, anchorsByChapter.get(index) || [])
: plainChapterXhtml(chapters[index]);
zip.file(`text/chapter-${index}.xhtml`, xhtml);
if (index % 64 === 0) report(0.1 + 0.75 * ((index + 1) / chapters.length));
}
report(0.85);
const title = label(options.title || split.title, wantMarkdown ? '未命名文档' : '未命名文本');
const manifest = chapters
.map((_, index) => `<item id="chapter-${index}" href="text/chapter-${index}.xhtml" media-type="application/xhtml+xml"/>`)
.concat('<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>')
.join('');
const spine = chapters.map((_, index) => `<itemref idref="chapter-${index}"/>`).join('');
const nav = navMarkup(entries.map((entry) => ({
label: entry.label,
depth: entry.depth,
href: `text/chapter-${entry.chapter}.xhtml${entry.anchor ? `#${entry.anchor}` : ''}`
})));
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml',
'<?xml version="1.0" encoding="UTF-8"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>');
zip.file('content.opf',
`<?xml version="1.0" encoding="UTF-8"?><package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">peoplelib-text-${Date.now()}</dc:identifier><dc:title>${xmlText(title)}</dc:title><dc:language>zh-CN</dc:language></metadata><manifest>${manifest}</manifest><spine>${spine}</spine></package>`);
zip.file('nav.xhtml',
`<html xmlns="${XHTML_NS}"><head><title>${xmlText(title)}</title></head><body><nav epub:type="toc" xmlns:epub="http://www.idpf.org/2007/ops">${nav}</nav></body></html>`);
// 纯文本压缩收益抵不上 CPU:32 MB 正文实测 STORE 打包约 340 ms
// 而这个 zip 只在内存里转手给 epub 适配器,不落盘。
const packed = await zip.generateAsync({ type: 'uint8array', compression: 'STORE' });
report(1);
return {
bytes: packed,
title,
chapterCount: chapters.length,
encoding: decoded.encoding,
confident: decoded.confident,
mode: markdown ? 'markdown' : 'plain',
charCount: decoded.text.length
};
}
export function normalizeTextFormat(format) {
const value = String(format == null ? '' : format).toLowerCase().replace(/^\./, '');
return ['md', 'markdown', 'mdown', 'mkd', 'mkdn'].includes(value) ? 'md' : 'txt';
}
export function createTextAdapter(format = 'txt') {
const inner = createEpubAdapter();
const sourceFormat = normalizeTextFormat(format);
let info = null;
function toInner(locator) {
const value = locator && typeof locator === 'object' ? locator : {};
return { kind: 'epub', chapter: value.chapter, offset: value.offset };
}
function fromInner(locator) {
const value = locator && typeof locator === 'object' ? locator : {};
return { kind: sourceFormat, chapter: value.chapter || 0, offset: value.offset || 0 };
}
async function load(bytes, options = {}) {
info = null;
const report = typeof options.onProgress === 'function'
? (value) => { try { options.onProgress(clamp(value, 0, 1)); } catch (e) { /* ignore */ } }
: null;
const built = await buildTextEpub(bytes, {
format: sourceFormat,
title: options.title,
limits: options.limits,
onProgress: report ? (value) => report(value * 0.7) : null
});
const result = await inner.load(built.bytes, {
...options,
onProgress: report ? (value) => report(0.7 + value * 0.3) : null
});
info = {
chapterCount: built.chapterCount,
title: built.title || result.title,
format: sourceFormat,
encoding: built.encoding,
confident: built.confident,
mode: built.mode,
charCount: built.charCount
};
return { ...result, ...info };
}
function renderTo(container, locator, options) {
return inner.renderTo(container, toInner(locator), options)
.then((result) => ({ ...result, locator: fromInner(result.locator) }));
}
async function toc() {
return (await inner.toc()).map((entry) => ({ ...entry, locator: fromInner(entry.locator) }));
}
function getSelection() {
const selection = inner.getSelection();
return selection ? { ...selection, locator: fromInner(selection.locator) } : null;
}
function textOf(locator, span) {
return inner.textOf(toInner(locator), span);
}
function visualViewportRect() {
const value = inner.visualViewportRect();
return value ? { ...value, locator: fromInner(value.locator) } : null;
}
function locatorLabel(locator) {
return inner.locatorLabel(toInner(locator));
}
function nextLocator(locator) {
const next = inner.nextLocator(toInner(locator));
return next ? fromInner(next) : null;
}
function prevLocator(locator) {
const previous = inner.prevLocator(toInner(locator));
return previous ? fromInner(previous) : null;
}
function percentOf(locator) {
return inner.percentOf(toInner(locator));
}
function locatorFromPercent(percent) {
return fromInner(inner.locatorFromPercent(percent));
}
function capturePinchAnchor(x, y) {
const anchor = inner.capturePinchAnchor(x, y);
return anchor ? { ...anchor, kind: sourceFormat } : null;
}
function restorePinchAnchor(anchor) {
inner.restorePinchAnchor(anchor);
}
function setLocatorChangeHandler(handler) {
inner.setLocatorChangeHandler(typeof handler === 'function'
? (locator, percent) => handler(fromInner(locator), percent)
: null);
}
function setTouchGestureHandler(handler) {
inner.setTouchGestureHandler(handler);
}
function documentInfo() {
return info ? { ...info } : null;
}
function destroy() {
inner.destroy();
info = null;
}
return {
load,
renderTo,
toc,
getSelection,
textOf,
visualViewportRect,
locatorLabel,
nextLocator,
prevLocator,
percentOf,
locatorFromPercent,
capturePinchAnchor,
restorePinchAnchor,
setLocatorChangeHandler,
setTouchGestureHandler,
documentInfo,
destroy
};
}
+97 -22
View File
@@ -9,6 +9,7 @@
--text-dim: #8b94a3;
--green: #3fb96f;
--danger: #d9534f;
--amber: #d69e2e;
--titlebar-start: #171b26;
--titlebar-end: #12141c;
--active-text: #0d1420;
@@ -35,6 +36,7 @@
--text-dim: #64748b;
--green: #35ad69;
--danger: #c93f3a;
--amber: #a96c0c;
--titlebar-start: #ffffff;
--titlebar-end: #f2f5fa;
--active-text: #ffffff;
@@ -184,14 +186,43 @@ body {
gap: 16px;
}
.card { cursor: pointer; }
/* 封面比例五花八门(生成的是 4:5,书源的常见 0.65~0.75,还有方图和横图)。
用 cover 铺满会按各自比例裁掉不同的边,同一套封面看上去缩放程度不一。
改成 contain 让整张封面完整显示:模糊放大的同一张图由 ::before 垫在**底层**
填掉留白。注意 ::before 会盖在父元素自己的背景之上,所以清晰的那层必须画在
::after 里,只靠 z-index 调不动父元素背景的层级。 */
.card-cover {
position: relative;
width: 100%; aspect-ratio: 3/4;
background: var(--bg-card) center/cover no-repeat;
background: var(--bg-card) center/contain no-repeat;
border: 1px solid var(--line); border-radius: 10px;
display: flex; align-items: center; justify-content: center;
padding: 10px; text-align: center;
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
}
.card-cover[data-cover-state="ready"]::before,
.card-cover[data-cover-state="ready"]::after {
content: "";
position: absolute;
inset: 0;
background-image: inherit;
background-repeat: no-repeat;
background-position: center;
pointer-events: none;
}
.card-cover[data-cover-state="ready"]::before {
z-index: 0;
background-size: cover;
filter: blur(12px) brightness(0.5);
transform: scale(1.15);
}
.card-cover[data-cover-state="ready"]::after {
z-index: 1;
background-size: contain;
}
/* 占位文字与角标必须浮在两层背景之上 */
.card-cover > * { position: relative; z-index: 2; }
.card-cover.readable { cursor: pointer; }
.card-cover.readable:focus-visible {
outline: 2px solid var(--accent);
@@ -209,12 +240,26 @@ body {
}
.card-sub { margin-top: 2px; font-size: 11px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-date { margin-top: 2px; font-size: 11px; color: var(--text-dim); }
.card-badge {
display: inline-block; margin-top: 4px; padding: 1px 8px;
font-size: 11px; border-radius: 10px;
background: rgba(63,185,111,0.15); color: var(--green);
/* 状态与笔记标识叠在封面右下角,不再占用封面下方的一行。
封面图案深浅不可控,故用不透明衬底而非半透明色块保证可读性 */
.card-cover-badges {
position: absolute;
bottom: 6px;
display: flex; gap: 4px;
max-width: calc(50% - 8px);
pointer-events: none;
}
.card-badge.miss { background: rgba(217,83,79,0.15); color: var(--danger); }
.card-cover-badges.start { left: 6px; }
.card-cover-badges.end { right: 6px; justify-content: flex-end; }
.card-badge {
padding: 1px 7px;
font-size: 10px; line-height: 1.6; border-radius: 10px;
white-space: nowrap;
background: var(--bg-card); color: var(--green);
border: 1px solid rgba(63,185,111,0.45);
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
}
.card-badge.miss { color: var(--danger); border-color: rgba(217,83,79,0.5); }
.empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim); padding: 60px 0; }
@@ -393,9 +438,12 @@ body {
.lib-card-actions .open-btn:hover { background: var(--accent-bright); }
.lib-card-actions .open-btn:disabled { background: var(--disabled-bg); color: var(--text-dim); cursor: not-allowed; }
.card-badge.note-count {
margin-left: 5px;
background: rgba(110,168,254,0.14);
color: var(--accent-bright);
border-color: rgba(110,168,254,0.5);
}
.card-badge.annotation-count {
color: var(--amber);
border-color: rgba(214,158,46,0.55);
}
/* 书库多选 */
@@ -418,19 +466,27 @@ body {
cursor: pointer;
}
.tb-btn.ghost.active { color: var(--accent-bright); border-color: var(--accent); }
#libraryTab.select-mode .card { position: relative; }
/* 定位上下文不能只在 select-mode 下建立:退出多选是同步移除类名,
而重绘要等 IPC 返回,其间残留的复选框会失去定位祖先,
直接按视口坐标飞到左上角标题上闪一下 */
.card { position: relative; }
.card-select {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
display: flex;
padding: 4px;
background: rgba(20,20,20,0.62);
border-radius: 6px;
cursor: pointer;
}
.card-select input { margin: 0; cursor: pointer; }
/* 用投影而不是衬底色块保证在浅色封面上也看得见:
加 padding + 背景板会让复选框看起来套了一圈很粗的边框 */
.card-select input {
margin: 0;
cursor: pointer;
filter: drop-shadow(0 0 1px rgba(0,0,0,0.9)) drop-shadow(0 1px 2px rgba(0,0,0,0.55));
}
/* 退出多选后重绘要等 IPC 返回,这期间立刻藏掉残留的复选框 */
#libraryTab:not(.select-mode) .card-select { display: none; }
#libraryTab.select-mode .card-cover { transition: none; }
#libraryTab.select-mode .card:hover .card-cover { transform: none; }
#libraryTab.select-mode .card.selected .card-cover { border-color: var(--accent); }
@@ -549,23 +605,30 @@ body {
outline: none;
}
.library-search input:focus { border-color: var(--accent); }
/* 标签叠在封面顶部右侧:左上角要留给多选复选框 */
.library-card-tags {
position: absolute;
top: 6px; right: 6px;
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 5px;
min-height: 18px;
max-width: calc(100% - 42px);
overflow: hidden;
pointer-events: none;
}
.library-card-tag {
max-width: 90px;
min-width: 0;
padding: 1px 6px;
border-radius: 8px;
background: rgba(110,168,254,0.1);
background: var(--bg-card);
border: 1px solid rgba(110,168,254,0.45);
color: var(--accent-bright);
font-size: 10px;
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
}
.library-organize-form { display: flex; flex-direction: column; gap: 12px; }
.library-organize-form label { color: var(--text-dim); font-size: 12px; }
@@ -968,6 +1031,7 @@ body {
}
.note-action:hover { border-color: var(--line); color: var(--text); }
.note-action.open { color: var(--accent-bright); }
.note-action.window { color: var(--accent-bright); }
.note-action.delete:hover { color: var(--danger); }
.note-edit-form { display: flex; flex-direction: column; gap: 10px; }
.note-type-choice-grid {
@@ -1022,9 +1086,12 @@ body {
.note-canvas-preview.template-pdf {
background: linear-gradient(145deg, #fff 0 68%, #f1f3f6 68% 100%);
}
.note-edit-form label { color: var(--text-dim); font-size: 12px; }
.note-edit-form textarea,
.note-edit-form select {
.note-edit-form label { color: var(--text-dim); font-size: 12px; min-width: 0; }
/* 必须排除 .canvas-note-root 与 .ql-toolbar 内部:画布工具栏和富文本工具栏都是
.note-edit-form 的后代,不加 :not() 的话工具栏里的下拉会被当成表单控件,吃到
margin-top 与 width:100%,表现为工具栏凭空高出 5px、各分组高度对不齐。 */
.note-edit-form textarea:not(.canvas-note-root textarea):not(.ql-toolbar textarea),
.note-edit-form select:not(.canvas-note-root select):not(.ql-toolbar select) {
width: 100%;
margin-top: 5px;
padding: 8px 10px;
@@ -1037,8 +1104,16 @@ body {
outline: none;
resize: vertical;
}
.note-edit-form textarea:focus,
.note-edit-form select:focus { border-color: var(--accent); }
/* 关联书籍 / 笔记本:书名可以很长,下拉框跟着长到占满整行,与右侧「标题」输入框
宽度悬殊。限宽后仍需 min-width:0,否则 select 的 min-content 以最长选项为准,
照样把整行顶宽。 */
.note-edit-form select:not(.canvas-note-root select):not(.ql-toolbar select) {
max-width: 320px;
min-width: 0;
text-overflow: ellipsis;
}
.note-edit-form textarea:not(.canvas-note-root textarea):focus,
.note-edit-form select:not(.canvas-note-root select):focus { border-color: var(--accent); }
.modal-box:has(.quill-note-editor),
.modal-box:has(.canvas-note-root) {
display: flex;
+36 -29
View File
@@ -12,6 +12,12 @@ const Library = (() => {
const selectedIds = new Set();
let visibleIds = [];
// 必须与 main.js 的 READABLE_EXT 保持一致。漏掉格式不会报错,
// 只是「阅读」按钮和封面点击静默消失,看上去像阅读器打不开这类文件
const READABLE_RE = /\.(pdf|epub|mobi|azw|azw3|txt|md)$/i;
const isReadableFile = (file) => !!file && file.exists
&& READABLE_RE.test(file.path || file.name || '');
const SORTERS = {
recent: (a, b) => (
(b.lastReadAt || 0) - (a.lastReadAt || 0)
@@ -209,11 +215,9 @@ const Library = (() => {
return items.filter((item) => selectedIds.has(String(item.id)));
}
function cardHtml(it, noteCounts) {
function cardHtml(it, noteCounts, annotationCounts) {
const openable = (it.files || []).some((file) => file.exists);
const readable = (it.files || []).some((file) => (
file.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(file.path || file.name || '')
));
const readable = (it.files || []).some(isReadableFile);
const badge = openable
? '<span class="card-badge">已下载</span>'
: ((it.files || []).length
@@ -223,8 +227,12 @@ const Library = (() => {
const noteBadge = noteCount > 0
? `<span class="card-badge note-count">笔记 ${noteCount}</span>`
: '';
const annotationCount = annotationCounts.get(String(it.id)) || 0;
const annotationBadge = annotationCount > 0
? `<span class="card-badge annotation-count">批注 ${annotationCount}</span>`
: '';
const tagBadges = (it.tags || []).slice(0, 3)
.map((tag) => `<span class="library-card-tag">${escapeHtml(tag)}</span>`)
.map((tag) => `<span class="library-card-tag" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`)
.join('');
const selectBox = selectMode
? `<label class="card-select" title="选择"><input type="checkbox" aria-label="选择${escapeHtml(it.title)}" /></label>`
@@ -236,12 +244,13 @@ const Library = (() => {
${selectBox}
<div class="card-cover${readable ? ' readable' : ''}" style="${coverStyle(it.cover)}"
data-cover-state="${it.cover ? 'ready' : 'pending'}"
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
<div class="card-cover-badges start">${badge}</div>
<div class="card-cover-badges end">${annotationBadge}${noteBadge}</div>
</div>
<div class="card-title" title="${escapeHtml(it.title)}">${escapeHtml(it.title)}</div>
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${badge}
${noteBadge}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
${selectMode ? '' : `<div class="lib-card-actions">
${readable ? cardAction('read', '阅读', true) : ''}
${cardAction('open', readable ? '外部打开' : '打开', !readable, !openable)}
@@ -281,14 +290,14 @@ const Library = (() => {
};
}
function reconcileCards(items, noteCounts) {
function reconcileCards(items, noteCounts, annotationCounts) {
const existing = new Map(
Array.from(grid.querySelectorAll(':scope > .card')).map((card) => [card.dataset.id, card])
);
const keep = new Set();
items.forEach((item, index) => {
const id = String(item.id);
const markup = cardHtml(item, noteCounts).trim();
const markup = cardHtml(item, noteCounts, annotationCounts).trim();
let card = existing.get(id);
if (!card || card.__peoplelibMarkup !== markup) {
const template = document.createElement('template');
@@ -314,9 +323,10 @@ const Library = (() => {
async function refresh(force) {
if (!force && !dirty) return;
const currentRefresh = ++refreshSeq;
const [res, noteCountResult, shelfResult, tagResult] = await Promise.all([
const [res, noteCountResult, annotationCountResult, shelfResult, tagResult] = await Promise.all([
window.api.library.list(),
window.api.reader.getNoteCounts().catch(() => null),
window.api.reader.getAnnotationCounts().catch(() => null),
window.api.library.listShelves(),
window.api.library.listTags()
]);
@@ -334,6 +344,7 @@ const Library = (() => {
if (selectedTag && !libraryTags.some((tag) => tag.name === selectedTag)) selectedTag = '';
renderOrganizationSidebar();
const noteCounts = noteCountsOf(noteCountResult);
const annotationCounts = noteCountsOf(annotationCountResult);
const allItems = res.data.slice();
const scopedItems = allItems.filter((item) => {
if (selectedShelf === '__uncategorized__' && item.shelfId) return false;
@@ -365,7 +376,7 @@ const Library = (() => {
syncSelectionUi();
return;
}
reconcileCards(items, noteCounts);
reconcileCards(items, noteCounts, annotationCounts);
syncSelectionUi();
}
@@ -659,8 +670,8 @@ const Library = (() => {
});
const shelfValue = $('libraryBulkShelf').value;
const errorEl = $('libraryBulkError');
const failures = [];
for (const item of targets) {
// 一次提交:逐条 update 会把整个书库索引重写 N 遍
const patches = targets.map((item) => {
const patch = {};
if (shelfValue !== '__keep__') patch.shelfId = shelfValue || null;
const kept = (item.tags || []).filter((tag) => !strip.includes(String(tag).toLocaleLowerCase()));
@@ -671,11 +682,11 @@ const Library = (() => {
}
});
patch.tags = merged;
const response = await window.api.library.update(item.id, patch);
if (!response || !response.ok) failures.push(item.title);
}
if (failures.length) {
errorEl.textContent = `${failures.length} 本未能保存:${failures.slice(0, 3).join('、')}`;
return { id: item.id, patch };
});
const response = await window.api.library.updateMany(patches);
if (!response || !response.ok) {
errorEl.textContent = (response && response.error) || '保存失败';
return false;
}
return true;
@@ -709,17 +720,13 @@ const Library = (() => {
}));
if (!choice) return;
const failures = [];
for (const item of targets) {
const removed = await window.api.library.remove(item.id, choice);
if (!removed || !removed.ok) failures.push(item.title);
}
const removed = await window.api.library.removeMany(targets.map((item) => item.id), choice);
setSelectMode(false);
if (failures.length) {
await confirmModal('部分移除失败', `${failures.length} 本未能移除:${failures.slice(0, 3).join('、')}`);
if (!removed || !removed.ok) {
await confirmModal('移除失败', (removed && removed.error) || '未知错误');
return;
}
statusEl.textContent = `已移除 ${targets.length}`;
statusEl.textContent = `已移除 ${(removed.data && removed.data.removed) || targets.length}`;
}
async function organizeBook(item) {
@@ -772,7 +779,7 @@ const Library = (() => {
const it = res.data;
if (act === 'read') {
const files = it.files || [];
const idx = files.findIndex((x) => x.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(x.path || x.name || ''));
const idx = files.findIndex(isReadableFile);
const r = await window.api.reader.open(id, idx >= 0 ? idx : undefined);
if (!r.ok) await confirmModal('无法阅读', r.error || '打开阅读器失败');
} else if (act === 'open') {
+610
View File
@@ -0,0 +1,610 @@
// 笔记独立窗口:单窗口多标签,形态与阅读器一致。
// 编辑器复用 MixedNote,读书/画布两类共用同一挂载入口。
//
// 「一标签一条」是数据安全约束:`reader:updateNote` 整条覆盖且无版本校验,
// 同一条笔记两个编辑器时后保存者会把前者内容整块吃掉。openTab 必须先查重。
//
// 笔记没有自动保存,切换标签只留在内存、不落盘,关闭标签或窗口才提示。
const api = window.api;
const $ = (id) => document.getElementById(id);
// 画布编辑器(fabric)吃内存,照阅读器的做法限制存活数并 LRU 回收。
// 回收前必须把未保存内容序列化进 pendingContent,否则回收即丢改动。
const MAX_LIVE_EDITORS = 3;
const el = {
uiThemeBtn: $('uiThemeBtn'),
minBtn: $('minBtn'),
maxBtn: $('maxBtn'),
closeBtn: $('closeBtn'),
loading: $('noteWindowLoading'),
error: $('noteWindowError'),
empty: $('noteWindowEmpty'),
tabsList: $('noteTabsList'),
tabViews: $('noteTabViews'),
tabTemplate: $('noteTabTemplate'),
dirtyModal: $('noteDirtyModal'),
dirtyNotice: $('noteDirtyNotice'),
dirtyCancelBtn: $('noteDirtyCancelBtn'),
dirtyDiscardBtn: $('noteDirtyDiscardBtn'),
dirtySaveBtn: $('noteDirtySaveBtn')
};
const params = new URLSearchParams(location.search);
const tabs = [];
let activeId = '';
let tabSeq = 0;
let touchSeq = 0;
let uiTheme = 'dark';
let collectionOptions = [];
let dirtyResolve = null;
let closing = false;
function errText(res, fallback) {
if (res && typeof res.error === 'string' && res.error) return res.error;
if (res instanceof Error && res.message) return res.message;
return fallback;
}
function showError(message) {
el.error.textContent = message;
el.error.classList.remove('hidden');
}
function clearError() {
el.error.textContent = '';
el.error.classList.add('hidden');
}
function activeTab() {
return tabs.find((tab) => tab.noteId === activeId) || null;
}
function tabOf(noteId) {
return tabs.find((tab) => tab.noteId === String(noteId)) || null;
}
function setStatus(tab, message, isError = false) {
if (!tab) return;
tab.dom.status.textContent = message;
tab.dom.status.classList.toggle('error', !!isError);
if (tab.statusTimer) clearTimeout(tab.statusTimer);
if (message) {
tab.statusTimer = setTimeout(() => { tab.dom.status.textContent = ''; }, 4000);
}
}
function applyUiTheme(next) {
uiTheme = next === 'light' ? 'light' : 'dark';
document.documentElement.dataset.uiTheme = uiTheme;
const label = uiTheme === 'light' ? '切换到暗色主题' : '切换到明亮主题';
el.uiThemeBtn.title = label;
el.uiThemeBtn.setAttribute('aria-label', label);
}
function tagsFromInput(value) {
return String(value || '')
.split(/[,]/)
.map((tag) => tag.trim())
.filter(Boolean);
}
function noteTypeOf(value) {
if (!value) return 'reading';
return value.noteType || (value.canvasContent ? 'canvas' : 'reading');
}
function noteLabel(note) {
const title = String((note && note.title) || '').trim();
if (title) return title;
const text = String((note && note.text) || '').replace(/\s+/g, ' ').trim();
if (text) return text.slice(0, 24);
return '未命名笔记';
}
/* --- 标签集上报 --- */
function reportTabs() {
if (!api.notes || !api.notes.tabsChanged) return;
const payload = tabs.map((tab) => ({ noteId: tab.noteId, entryId: tab.entryId }));
Promise.resolve(api.notes.tabsChanged(payload)).catch(() => { /* 下次变更时重试 */ });
}
/* --- 标签条 --- */
function renderTabs() {
el.tabsList.textContent = '';
for (const tab of tabs) {
const item = document.createElement('div');
item.className = 'doctab' + (tab.noteId === activeId ? ' active' : '');
item.dataset.noteId = tab.noteId;
item.setAttribute('role', 'tab');
item.title = tab.dirty ? `${noteLabel(tab.note)}(未保存)` : noteLabel(tab.note);
const name = document.createElement('span');
name.className = 'doctab-name';
name.textContent = noteLabel(tab.note);
item.appendChild(name);
const fmt = document.createElement('span');
fmt.className = 'doctab-fmt';
fmt.textContent = tab.noteType === 'canvas' ? '画布' : '读书';
item.appendChild(fmt);
if (tab.dirty) {
const dot = document.createElement('span');
dot.className = 'doctab-dirty';
dot.title = '有未保存的修改';
item.appendChild(dot);
}
const close = document.createElement('button');
close.className = 'doctab-close';
close.type = 'button';
close.title = '关闭';
close.textContent = '\u2715';
close.addEventListener('click', (event) => {
event.stopPropagation();
closeTab(tab.noteId);
});
item.appendChild(close);
item.addEventListener('click', () => { activate(tab.noteId); });
el.tabsList.appendChild(item);
}
el.empty.classList.toggle('hidden', tabs.length > 0);
}
/* --- 笔记本下拉 --- */
async function refreshCollections() {
let res;
try {
res = await api.reader.listCollections();
} catch (e) {
return;
}
if (!res || !res.ok || !Array.isArray(res.data)) return;
collectionOptions = res.data.map((item) => ({
id: String(item.id),
name: item.name || '未命名笔记本'
}));
for (const tab of tabs) fillCollections(tab);
}
function fillCollections(tab) {
const select = tab.dom.collection;
// 重建选项后要还原用户当前的选择。首次填充才回落到笔记自身的笔记本,
// 否则用户刚改成「未分类」会被下一次刷新弹回原值。
const current = tab.collectionFilled
? select.value
: (tab.note.collectionId == null ? '' : String(tab.note.collectionId));
select.textContent = '';
const none = document.createElement('option');
none.value = '';
none.textContent = '未分类';
select.appendChild(none);
for (const item of collectionOptions) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
select.value = current;
tab.collectionFilled = true;
}
/* --- 标签生命周期 --- */
function buildView(tab) {
const view = document.createElement('div');
view.className = 'note-tab-view inactive';
view.dataset.noteId = tab.noteId;
const form = el.tabTemplate.content.firstElementChild.cloneNode(true);
view.appendChild(form);
el.tabViews.appendChild(view);
tab.view = view;
tab.dom = {
form,
title: form.querySelector('.note-window-title'),
badge: form.querySelector('.note-window-badge'),
editorHost: form.querySelector('.note-window-editor'),
quote: form.querySelector('.note-window-quote'),
collection: form.querySelector('.note-window-collection'),
tags: form.querySelector('.note-window-tags-input'),
pinned: form.querySelector('.note-window-pinned'),
status: form.querySelector('.note-window-status'),
saveBtn: form.querySelector('.note-window-save')
};
form.addEventListener('submit', (event) => {
event.preventDefault();
save(tab);
});
for (const node of [tab.dom.title, tab.dom.tags]) {
node.addEventListener('input', () => markDirty(tab));
}
for (const node of [tab.dom.collection, tab.dom.pinned]) {
node.addEventListener('change', () => markDirty(tab));
}
}
function markDirty(tab) {
if (tab.dirty) return;
tab.dirty = true;
renderTabs();
}
function fillFields(tab) {
const note = tab.note;
tab.dom.badge.textContent = tab.noteType === 'canvas' ? '画布笔记' : '读书笔记';
tab.dom.title.value = String(note.title || '');
tab.dom.tags.value = Array.isArray(note.tags) ? note.tags.join(', ') : '';
tab.dom.pinned.checked = !!note.pinned;
tab.dom.quote.textContent = String(note.quote || '');
tab.dom.quote.classList.toggle('hidden', !String(note.quote || '').trim());
fillCollections(tab);
}
function mountEditor(tab) {
if (tab.editor) tab.editor.destroy();
// 恢复顺序不能反:pendingContent 是回收前序列化的未保存内容,
// 优先它才能让「回收后切回来」看到用户改过的样子而不是磁盘上的旧值。
const rich = tab.noteType === 'reading'
? (tab.pendingContent && tab.pendingContent.richContent)
|| tab.note.richContent
|| window.RichNote.fromText(tab.note.text)
: null;
const canvas = tab.noteType === 'canvas'
? (tab.pendingContent && tab.pendingContent.canvasContent) || tab.note.canvasContent || null
: null;
tab.editor = window.MixedNote.mount(tab.dom.editorHost, rich, canvas, {
noteType: tab.noteType,
onError: (message) => setStatus(tab, message, true)
});
tab.loaded = true;
tab.pendingContent = null;
// 基线取挂载后的序列化结果,而不是磁盘原值:画布会做 version 1→2 迁移等
// 规范化,拿磁盘值当基线会让刚打开的标签就被判定为"已修改"。
tab.baselineKey = null;
Promise.resolve(tab.editor.ready())
.then(() => { if (tab.editor && tab.baselineKey == null) tab.baselineKey = contentKey(tab); })
.catch(() => { /* 画布没起来时不做脏检查 */ });
watchEditorChanges(tab);
}
// MixedNote 没有 change 回调。用事件当"可能改过"的触发器,再比对序列化内容
// 才置 dirty:画布上单纯点一下工具或按方向键也会冒泡出 pointerdown/keydown
// 只按事件置 dirty 会让没动过内容的标签在关窗时弹出无意义的未保存提示。
function watchEditorChanges(tab) {
const host = tab.dom.editorHost;
unwatchEditorChanges(tab);
const handler = () => scheduleDirtyCheck(tab);
const types = ['input', 'pointerup', 'keyup'];
tab.editorWatcher = types.map((type) => [type, handler]);
for (const type of types) host.addEventListener(type, handler);
}
function unwatchEditorChanges(tab) {
if (!tab.editorWatcher) return;
for (const [type, handler] of tab.editorWatcher) {
tab.dom.editorHost.removeEventListener(type, handler);
}
tab.editorWatcher = null;
}
function contentKey(tab) {
if (!tab.editor) return tab.baselineKey;
return tab.noteType === 'canvas'
? JSON.stringify(tab.editor.canvasContent())
: JSON.stringify(tab.editor.richContent());
}
function scheduleDirtyCheck(tab) {
if (tab.dirty || tab.dirtyTimer || tab.baselineKey == null) return;
// 画布 fabric 的对象要等一帧才落到 content(),立刻比对会读到旧值
tab.dirtyTimer = setTimeout(() => {
tab.dirtyTimer = 0;
if (tab.dirty || !tab.editor || tab.baselineKey == null) return;
if (contentKey(tab) !== tab.baselineKey) markDirty(tab);
}, 250);
}
function touch(tab) {
tab.touch = ++touchSeq;
}
// 回收非活跃标签的编辑器。未保存内容先序列化,绝不能直接 destroy。
async function evictExcept(keep) {
const live = tabs.filter((tab) => tab.editor);
if (live.length <= MAX_LIVE_EDITORS) return;
const victims = live
.filter((tab) => tab !== keep && tab.noteId !== activeId)
.sort((a, b) => a.touch - b.touch);
let count = live.length;
while (count > MAX_LIVE_EDITORS && victims.length) {
const victim = victims.shift();
await release(victim);
count -= 1;
}
}
async function release(tab) {
if (!tab.editor) return;
try {
await tab.editor.ready();
} catch (e) { /* 画布没加载成功也要继续回收 */ }
tab.pendingContent = {
richContent: tab.noteType === 'reading' ? tab.editor.richContent() : null,
canvasContent: tab.noteType === 'canvas' ? tab.editor.canvasContent() : null,
text: tab.noteType === 'reading' ? tab.editor.text() : ''
};
unwatchEditorChanges(tab);
if (tab.dirtyTimer) { clearTimeout(tab.dirtyTimer); tab.dirtyTimer = 0; }
try { tab.editor.destroy(); } catch (e) { /* ignore */ }
tab.editor = null;
tab.loaded = false;
}
async function openTab(entryId, noteId) {
const key = String(noteId || '');
if (!key) return;
const exist = tabOf(key);
if (exist) {
await activate(key);
return;
}
let res;
try {
res = await api.notes.getOne(entryId, key);
} catch (e) {
res = { ok: false, error: (e && e.message) || String(e) };
}
if (!res || !res.ok || !res.data) {
showError(`无法打开这条笔记:${errText(res, '笔记不存在或已被删除')}`);
el.loading.classList.add('hidden');
return;
}
clearError();
const note = res.data;
const tab = {
id: ++tabSeq,
noteId: String(note.id),
entryId: String(note.entryId),
note,
noteType: noteTypeOf(note),
view: null,
dom: null,
editor: null,
editorWatcher: null,
pendingContent: null,
baselineKey: null,
collectionFilled: false,
loaded: false,
dirty: false,
saving: false,
statusTimer: 0,
dirtyTimer: 0,
touch: 0
};
buildView(tab);
fillFields(tab);
tabs.push(tab);
el.loading.classList.add('hidden');
reportTabs();
await activate(tab.noteId);
}
async function activate(noteId) {
const tab = tabOf(noteId);
if (!tab) return;
const prev = activeTab();
if (prev && prev !== tab) prev.view.classList.add('inactive');
activeId = tab.noteId;
tab.view.classList.remove('inactive');
touch(tab);
if (!tab.loaded) {
mountEditor(tab);
await evictExcept(tab);
}
renderTabs();
document.title = `${noteLabel(tab.note)} - PeopleLib`;
if (tab.dom) tab.dom.title.focus();
}
// 未保存时的三选一。取消返回 null,让调用方原样放弃关闭动作。
function confirmDirty(tab) {
if (dirtyResolve) return Promise.resolve(null);
el.dirtyNotice.textContent = `${noteLabel(tab.note)}」有未保存的修改,关闭后会丢失。`;
el.dirtyModal.classList.remove('hidden');
requestAnimationFrame(() => el.dirtyCancelBtn.focus());
return new Promise((resolve) => { dirtyResolve = resolve; });
}
function settleDirty(choice) {
if (!dirtyResolve) return;
const resolve = dirtyResolve;
dirtyResolve = null;
el.dirtyModal.classList.add('hidden');
resolve(choice);
}
// force 用于笔记已在别处被删除:此时不能再提示保存,
// 保存会把已删的笔记整条写回去。
async function closeTab(noteId, force = false) {
const tab = tabOf(noteId);
if (!tab) return true;
if (!force && tab.dirty) {
await activate(tab.noteId);
const choice = await confirmDirty(tab);
if (choice === 'cancel' || choice == null) return false;
if (choice === 'save') {
const ok = await save(tab);
if (!ok) return false;
}
}
await release(tab);
tab.view.remove();
if (tab.statusTimer) clearTimeout(tab.statusTimer);
const index = tabs.indexOf(tab);
if (index >= 0) tabs.splice(index, 1);
if (activeId === tab.noteId) {
activeId = '';
const next = tabs[Math.min(index, tabs.length - 1)];
if (next) await activate(next.noteId);
}
renderTabs();
reportTabs();
if (!tabs.length && !closing) api.close();
return true;
}
async function save(tab) {
if (!tab || tab.saving) return false;
if (tab.editor) await tab.editor.ready();
const content = tab.editor
? {
richContent: tab.noteType === 'reading' ? tab.editor.richContent() : null,
canvasContent: tab.noteType === 'canvas' ? tab.editor.canvasContent() : null,
text: tab.noteType === 'reading' ? tab.editor.text() : '',
hasContent: tab.editor.hasContent()
}
: {
richContent: tab.pendingContent ? tab.pendingContent.richContent : null,
canvasContent: tab.pendingContent ? tab.pendingContent.canvasContent : null,
text: tab.pendingContent ? tab.pendingContent.text : '',
hasContent: !!(tab.pendingContent
&& (tab.pendingContent.richContent || tab.pendingContent.canvasContent))
};
if (!content.hasContent && !String(tab.note.quote || '').trim()) {
setStatus(tab, '请输入笔记内容', true);
return false;
}
// 只提交本窗口负责的字段。locator / documentKey / quote 等定位信息保持原样,
// 独立窗口没有正文上下文,跟着一起写回会把摘录的定位覆盖成空。
const patch = {
noteType: tab.noteType,
title: tab.dom.title.value.trim(),
...(tab.noteType === 'canvas'
? { canvasContent: content.canvasContent }
: { text: String(content.text || '').trim(), richContent: content.richContent }),
collectionId: tab.dom.collection.value || null,
tags: tagsFromInput(tab.dom.tags.value),
pinned: tab.dom.pinned.checked
};
tab.saving = true;
tab.dom.saveBtn.disabled = true;
let res;
try {
res = await api.reader.updateNote(tab.entryId, tab.noteId, patch);
} catch (e) {
res = { ok: false, error: (e && e.message) || String(e) };
} finally {
tab.saving = false;
tab.dom.saveBtn.disabled = false;
}
if (!res || !res.ok) {
setStatus(tab, `保存失败:${errText(res, '未知错误')}`, true);
return false;
}
if (res.data) tab.note = { ...tab.note, ...res.data };
tab.dirty = false;
// 基线要跟着落盘内容前移,否则保存完立刻又被判定为已修改
if (tab.editor) tab.baselineKey = contentKey(tab);
renderTabs();
setStatus(tab, '已保存');
return true;
}
// 关窗前逐个处理未保存标签。任一取消就中止关闭,
// 处理完才允许主进程真正销毁窗口。
async function prepareClose() {
if (closing) return;
closing = true;
for (const tab of tabs.slice()) {
if (!tab.dirty) continue;
await activate(tab.noteId);
const choice = await confirmDirty(tab);
if (choice === 'cancel' || choice == null) {
await abortClose();
return;
}
if (choice === 'save') {
const ok = await save(tab);
if (!ok) { await abortClose(); return; }
} else {
tab.dirty = false;
}
}
try {
await api.notes.shutdownReady();
} catch (e) {
await abortClose();
}
}
// 取消关闭必须通知主进程复位:否则主进程的 closePending 一直为真,
// 下次点关闭会被当成"正在处理"直接忽略,而看门狗仍会在十秒后销毁窗口。
async function abortClose() {
closing = false;
if (!api.notes.cancelClose) return;
try {
await api.notes.cancelClose();
} catch (e) { /* 主进程已退出时无所谓 */ }
}
function bind() {
el.minBtn.addEventListener('click', () => api.minimize());
el.maxBtn.addEventListener('click', () => api.maximize());
el.closeBtn.addEventListener('click', () => api.close());
el.uiThemeBtn.addEventListener('click', () => {
applyUiTheme(uiTheme === 'dark' ? 'light' : 'dark');
Promise.resolve(api.ui.setTheme(uiTheme)).catch(() => { /* 主题偏好丢失不影响编辑 */ });
});
el.dirtyCancelBtn.addEventListener('click', () => settleDirty('cancel'));
el.dirtyDiscardBtn.addEventListener('click', () => settleDirty('discard'));
el.dirtySaveBtn.addEventListener('click', () => settleDirty('save'));
window.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
save(activeTab());
return;
}
if (event.key === 'Escape' && dirtyResolve) settleDirty('cancel');
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'w') {
event.preventDefault();
if (activeId) closeTab(activeId);
}
});
if (api.notes.onOpenTab) {
api.notes.onOpenTab((data) => {
if (!data || !data.noteId) return;
openTab(data.entryId, data.noteId);
});
}
if (api.notes.onCloseTab) {
api.notes.onCloseTab(async (data) => {
const ids = data && Array.isArray(data.noteIds) ? data.noteIds : [];
// 笔记已被删除,force 关闭:再提示保存会把已删条目写回去
for (const id of ids) await closeTab(String(id), true);
});
}
if (api.notes.onPrepareClose) api.notes.onPrepareClose(() => { prepareClose(); });
api.reader.onNotesChanged(() => { refreshCollections(); });
}
(async function start() {
bind();
try {
const saved = await api.ui.getTheme();
applyUiTheme(saved && saved.ok ? saved.data : 'dark');
} catch (e) {
applyUiTheme('dark');
}
api.ui.onThemeChanged((next) => applyUiTheme(next));
await refreshCollections();
await openTab(params.get('entryId') || '', params.get('noteId') || '');
})();
+44 -2
View File
@@ -21,6 +21,7 @@ const Notes = (() => {
let selectedTag = '';
let selectedNoteType = '';
let searchText = '';
let openWindowIds = new Set();
let listEl;
let statusEl;
let collectionListEl;
@@ -92,6 +93,27 @@ const Notes = (() => {
render();
};
});
if (window.api.notes && window.api.notes.onWindowsChanged) {
window.api.notes.onWindowsChanged((ids) => {
openWindowIds = new Set((Array.isArray(ids) ? ids : []).map((id) => String(id)));
render();
});
refreshOpenWindows();
}
}
async function refreshOpenWindows() {
if (!window.api.notes || !window.api.notes.openWindows) return;
let res;
try {
res = await window.api.notes.openWindows();
} catch (error) {
return;
}
if (!res || !res.ok || !Array.isArray(res.data)) return;
openWindowIds = new Set(res.data.map((id) => String(id)));
render();
}
function markDirty() {
@@ -327,6 +349,7 @@ const Notes = (() => {
function renderNote(note, collectionNames) {
const card = document.createElement('article');
card.className = 'note-card';
card.dataset.noteId = String(note.id);
card.dataset.noteType = note.noteType || (note.canvasContent ? 'canvas' : 'reading');
if (note.pinned) card.classList.add('pinned');
@@ -445,8 +468,11 @@ const Notes = (() => {
const actions = document.createElement('div');
actions.className = 'note-actions';
const editButton = actionButton('编辑', 'edit');
editButton.onclick = () => editNote(note);
// 独立窗口是单窗口多标签,这里的"已开"指这条笔记已占了一个标签
const windowOpen = openWindowIds.has(String(note.id));
// 已开标签时不再开模态:同一条笔记两处编辑,后保存者会整条覆盖前者
const editButton = actionButton(windowOpen ? '在窗口中编辑' : '编辑', 'edit');
editButton.onclick = () => (windowOpen ? openNoteWindow(note) : editNote(note));
const deleteButton = actionButton('删除', 'delete');
deleteButton.onclick = () => deleteNote(note);
if (note.associated !== false) {
@@ -454,6 +480,9 @@ const Notes = (() => {
openButton.onclick = () => openNote(note);
actions.appendChild(openButton);
}
const windowButton = actionButton(windowOpen ? '切到窗口' : '独立窗口', 'window');
windowButton.onclick = () => openNoteWindow(note);
actions.appendChild(windowButton);
actions.append(editButton, deleteButton);
footer.append(meta, actions);
card.appendChild(footer);
@@ -481,6 +510,19 @@ const Notes = (() => {
});
}
async function openNoteWindow(note) {
let result;
try {
result = await window.api.notes.openWindow(note.entryId, note.id);
} catch (error) {
await confirmModal('无法打开', errorText(error, '打开笔记窗口失败'));
return;
}
if (!result || !result.ok) {
await confirmModal('无法打开', errorText(result && result.error, '打开笔记窗口失败'));
}
}
async function openNote(note) {
if (note.associated === false || !note.entryId) {
await confirmModal('无法打开', '这条笔记缺少书籍定位信息。');