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:
@@ -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');
|
||||
}
|
||||
|
||||
// 最小可解码 JPEG:SOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
|
||||
function jpeg(seed, size = 64) {
|
||||
const bytes = Buffer.alloc(size, seed & 0xff);
|
||||
bytes[0] = 0xff;
|
||||
bytes[1] = 0xd8;
|
||||
bytes[2] = 0xff;
|
||||
bytes[3] = 0xe0;
|
||||
bytes[size - 2] = 0xff;
|
||||
bytes[size - 1] = 0xd9;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
|
||||
function countWrites(matcher, fn) {
|
||||
const originalOpen = fs.openSync;
|
||||
let writes = 0;
|
||||
fs.openSync = function counting(target, flags, ...rest) {
|
||||
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
|
||||
return originalOpen.call(this, target, flags, ...rest);
|
||||
};
|
||||
try { fn(); } finally { fs.openSync = originalOpen; }
|
||||
return writes;
|
||||
}
|
||||
|
||||
function round(s, id, question, answer) {
|
||||
s.appendUser(id, { text: question, task: 'ask' });
|
||||
const placeholder = s.appendAssistant(id, {});
|
||||
return s.finishAssistant(id, placeholder.id, { text: answer });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
for (const dir of dirs) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// --- 基本读写 ---
|
||||
|
||||
test('会话按 per-session 文件落盘,索引可枚举', () => {
|
||||
const { sessions, dir } = fresh('basic');
|
||||
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
|
||||
const b = sessions.create({ entryId: 'e2' });
|
||||
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
|
||||
assert.strictEqual(a.entryId, 'e1');
|
||||
assert.strictEqual(b.entryId, 'e2');
|
||||
const files = fs.readdirSync(sessionDir(dir)).sort();
|
||||
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
|
||||
'每个会话应独占一个文件,加上一份索引');
|
||||
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
|
||||
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
|
||||
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
|
||||
});
|
||||
|
||||
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
|
||||
const { sessions } = fresh('global');
|
||||
const g = sessions.create({});
|
||||
assert.strictEqual(g.entryId, 'system:global-chat');
|
||||
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
|
||||
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
|
||||
});
|
||||
|
||||
test('list 按 pinned desc, updatedAt desc 排序', () => {
|
||||
const { sessions } = fresh('sort');
|
||||
const a = sessions.create({ title: 'a' });
|
||||
const b = sessions.create({ title: 'b' });
|
||||
const c = sessions.create({ title: 'c' });
|
||||
sessions.rename(b.id, 'b2');
|
||||
sessions.setPinned(a.id, true);
|
||||
const ids = sessions.list().map((row) => row.id);
|
||||
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
|
||||
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
|
||||
});
|
||||
|
||||
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
|
||||
const { sessions } = fresh('round');
|
||||
const meta = sessions.create({ entryId: 'e1' });
|
||||
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
|
||||
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
|
||||
const placeholder = sessions.appendAssistant(meta.id, {});
|
||||
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
|
||||
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
|
||||
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
|
||||
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
|
||||
const read = sessions.messages(meta.id);
|
||||
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
|
||||
assert.strictEqual(read.messages[1].text, '这是回答');
|
||||
assert.strictEqual(read.meta.messageCount, 2);
|
||||
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
|
||||
});
|
||||
|
||||
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
|
||||
const { sessions } = fresh('finish');
|
||||
const meta = sessions.create({});
|
||||
sessions.appendUser(meta.id, { text: 'q' });
|
||||
const p1 = sessions.appendAssistant(meta.id, {});
|
||||
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
|
||||
assert.strictEqual(cancelled.cancelled, true);
|
||||
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
|
||||
sessions.appendUser(meta.id, { text: 'q2' });
|
||||
const p2 = sessions.appendAssistant(meta.id, {});
|
||||
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
|
||||
assert.strictEqual(failed.error, '接口超时');
|
||||
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
|
||||
});
|
||||
|
||||
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
|
||||
const { sessions, dir } = fresh('lifecycle');
|
||||
const meta = sessions.create({ entryId: 'e1', title: '保留' });
|
||||
round(sessions, meta.id, '问', '答');
|
||||
const cleared = sessions.clear(meta.id);
|
||||
assert.strictEqual(cleared.messageCount, 0);
|
||||
assert.strictEqual(cleared.droppedMessages, 0);
|
||||
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
|
||||
assert.strictEqual(sessions.list().length, 1);
|
||||
assert.strictEqual(sessions.remove(meta.id), true);
|
||||
assert.strictEqual(sessions.remove(meta.id), false);
|
||||
assert.strictEqual(sessions.list().length, 0);
|
||||
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
|
||||
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
|
||||
});
|
||||
|
||||
test('messages 支持 limit 与 before 游标翻页', () => {
|
||||
const { sessions } = fresh('paging');
|
||||
const meta = sessions.create({});
|
||||
for (let i = 0; i < 5; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||
const tail = sessions.messages(meta.id, { limit: 4 });
|
||||
assert.strictEqual(tail.messages.length, 4);
|
||||
assert.strictEqual(tail.hasMore, true);
|
||||
assert.strictEqual(tail.messages[3].text, '答4');
|
||||
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
|
||||
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
|
||||
'before 必须是排他的,否则翻页会重复一条');
|
||||
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
|
||||
});
|
||||
|
||||
// --- 字段限长 ---
|
||||
|
||||
test('字段限长与 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
@@ -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 历史进 messages,system 仍在顶层', async () => {
|
||||
const body = await captureBody('anthropic', {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '新问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '旧问题' },
|
||||
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||
]
|
||||
});
|
||||
assert.strictEqual(typeof body.system, 'string');
|
||||
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
|
||||
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||
assert.strictEqual(body.messages[0].content, '旧问题');
|
||||
assert.strictEqual(body.messages[1].content, '旧回答');
|
||||
assert.match(body.messages[2].content, /新问题/);
|
||||
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
|
||||
});
|
||||
|
||||
test('openai-responses 历史进 input,instructions 与 store:false 保持', async () => {
|
||||
const body = await captureBody('openai-responses', {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '新问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '旧问题' },
|
||||
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||
]
|
||||
});
|
||||
assert.strictEqual(typeof body.instructions, 'string');
|
||||
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
|
||||
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
|
||||
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
|
||||
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
|
||||
assert.strictEqual(body.input[0].content, '旧问题');
|
||||
assert.strictEqual(body.input[1].content, '旧回答');
|
||||
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
|
||||
assert.strictEqual(body.input[2].content[0].type, 'input_text');
|
||||
assert.match(body.input[2].content[0].text, /新问题/);
|
||||
});
|
||||
|
||||
test('历史里的图像不被重发,只发当前轮的图', async () => {
|
||||
const image = visualContext().image;
|
||||
const history = [
|
||||
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||
];
|
||||
const args = {
|
||||
task: 'ask',
|
||||
text: '',
|
||||
question: '这张呢',
|
||||
visualContexts: [visualContext()],
|
||||
history
|
||||
};
|
||||
|
||||
const chat = await captureBody('chat-completions', args, { vision: true });
|
||||
const chatImages = chat.messages.flatMap(
|
||||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
|
||||
);
|
||||
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
|
||||
|
||||
const anthropic = await captureBody('anthropic', args, { vision: true });
|
||||
const anthropicImages = anthropic.messages.flatMap(
|
||||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
|
||||
);
|
||||
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
|
||||
|
||||
const responses = await captureBody('openai-responses', args, { vision: true });
|
||||
const responseImages = responses.input.flatMap(
|
||||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
|
||||
);
|
||||
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
|
||||
|
||||
const historyJson = JSON.stringify(history);
|
||||
assert.strictEqual(historyJson, JSON.stringify([
|
||||
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||
]), '不该原地改写调用方传进来的历史数组');
|
||||
});
|
||||
|
||||
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
|
||||
const body = await captureBody('anthropic', {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '新问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
|
||||
{ id: 'b', role: 'user', text: '真正的第一问' },
|
||||
{ id: 'c', role: 'assistant', text: '第一答' }
|
||||
]
|
||||
});
|
||||
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
|
||||
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
|
||||
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||
assert.strictEqual(body.messages[0].content, '真正的第一问');
|
||||
});
|
||||
|
||||
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '当前问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '上一问' },
|
||||
{ id: 'b', role: 'assistant', text: '上一答' },
|
||||
{ id: 'c', role: 'user', text: '没等到回答的追问' }
|
||||
]
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||
for (let i = 1; i < roles.length; i++) {
|
||||
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
|
||||
}
|
||||
const flat = JSON.stringify(items);
|
||||
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
|
||||
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
|
||||
}
|
||||
});
|
||||
|
||||
test('历史内部相邻同角色被合并而不是丢弃', async () => {
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '当前问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '连问一' },
|
||||
{ id: 'b', role: 'user', text: '连问二' },
|
||||
{ id: 'c', role: 'assistant', text: '连答一' },
|
||||
{ id: 'd', role: 'assistant', text: '连答二' }
|
||||
]
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||
assert.deepStrictEqual(
|
||||
roles,
|
||||
['user', 'assistant', 'user'],
|
||||
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
|
||||
);
|
||||
const flat = JSON.stringify(items);
|
||||
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
|
||||
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('历史含空文本时不产生空 content', async () => {
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '当前问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '有效提问' },
|
||||
{ id: 'b', role: 'assistant', text: ' ' },
|
||||
{ id: 'c', role: 'assistant', text: '' },
|
||||
{ id: 'd', role: 'user', text: null },
|
||||
{ id: 'e', role: 'assistant', text: '有效回答' },
|
||||
null
|
||||
]
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
for (const item of items) {
|
||||
const text = typeof item.content === 'string'
|
||||
? item.content
|
||||
: JSON.stringify(item.content);
|
||||
assert.ok(text && text.trim(), `${protocol} 出现空 content,Anthropic 不接受空字符串`);
|
||||
}
|
||||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
|
||||
}
|
||||
});
|
||||
|
||||
test('历史按旧到新排列,当前轮在最后', async () => {
|
||||
const history = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
|
||||
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
|
||||
}
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '问题4',
|
||||
history
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
|
||||
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
|
||||
.map((mark) => flat.findIndex((s) => s.includes(mark)));
|
||||
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
|
||||
for (let i = 1; i < order.length; i++) {
|
||||
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
|
||||
}
|
||||
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildMessages 也接受历史参数', () => {
|
||||
const ai = setup();
|
||||
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
|
||||
{ role: 'user', text: '旧问题' },
|
||||
{ role: 'assistant', text: '旧回答' }
|
||||
]);
|
||||
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
|
||||
assert.strictEqual(msgs[1].content, '旧问题');
|
||||
assert.match(msgs[3].content, /新问题/);
|
||||
});
|
||||
|
||||
@@ -151,6 +151,94 @@ test('主文件损坏时优先从原子写入备份恢复', () => {
|
||||
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
|
||||
});
|
||||
|
||||
test('批注计数统计对象总数,随内容变化失效', () => {
|
||||
const { store, dir } = fresh();
|
||||
const k = key('a.pdf');
|
||||
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }, { type: 'Path' }] });
|
||||
store.setPage('book', k, 4, { objects: [{ type: 'IText' }] });
|
||||
// 是对象总数而非批注页数
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 3 });
|
||||
|
||||
// mtime + size 缓存必须在内容变化后失效
|
||||
store.setPage('book', k, 4, { objects: [] });
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
store.setPage('book', k, 1, { objects: [] });
|
||||
assert.deepStrictEqual(store.getCounts(), {});
|
||||
|
||||
// 同一条目的不同文档版本累加
|
||||
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }] });
|
||||
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
|
||||
// 损坏文件计 0 而不是抛错,否则整个书库都拿不到计数
|
||||
fs.writeFileSync(path.join(dir, 'reader-annotations', 'broken.json'), '{ bad');
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
|
||||
store.forget('book');
|
||||
assert.deepStrictEqual(store.getCounts(), {});
|
||||
});
|
||||
|
||||
test('删除后重建同名批注文件不会命中旧缓存', () => {
|
||||
const { store, dir } = fresh();
|
||||
const folder = path.join(dir, 'reader-annotations');
|
||||
const file = path.join(folder, 'book.json');
|
||||
const k = key('a.pdf');
|
||||
store.setPage('book', k, 1, { objects: [{ t: 'aa' }, { t: 'bb' }] });
|
||||
// 固定到整毫秒,否则 utimesSync 无法精确复现原 mtime
|
||||
const pinned = new Date(1700000000000);
|
||||
fs.utimesSync(file, pinned, pinned);
|
||||
const stale = fs.statSync(file);
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
|
||||
// 构造与旧文件同尺寸、同 mtime 但内容不同的文件:
|
||||
// 缓存键只有 mtime+size,forget 不清缓存就会返回过期的 2
|
||||
store.forget('book');
|
||||
const rebuilt = JSON.parse(JSON.stringify({
|
||||
version: 1,
|
||||
entryId: 'book',
|
||||
documents: { [k]: { pages: { 1: { objects: [{ t: 'aa' }], updatedAt: 0 } }, updatedAt: 0 } }
|
||||
}));
|
||||
let text = JSON.stringify(rebuilt, null, 2);
|
||||
assert.ok(text.length < stale.size, '重建内容应短于原文件才能补齐到同尺寸');
|
||||
rebuilt.documents[k].pages['1'].objects[0].t = 'aa'.padEnd(2 + (stale.size - text.length), 'z');
|
||||
text = JSON.stringify(rebuilt, null, 2);
|
||||
assert.strictEqual(Buffer.byteLength(text, 'utf8'), stale.size, '需精确构造同尺寸文件');
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
fs.writeFileSync(file, text, 'utf8');
|
||||
fs.utimesSync(file, pinned, pinned);
|
||||
assert.strictEqual(fs.statSync(file).mtimeMs, stale.mtimeMs, '需精确复现同 mtime');
|
||||
assert.strictEqual(fs.statSync(file).size, stale.size, '需精确复现同尺寸');
|
||||
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 1 });
|
||||
});
|
||||
|
||||
test('孤立批注按体积报告并可批量回收', () => {
|
||||
const { store, dir } = fresh();
|
||||
const k = key('a.pdf');
|
||||
store.setPage('kept', k, 1, { objects: [{ t: 'Rect' }] });
|
||||
store.setPage('gone_a', k, 1, { objects: [{ t: 'Rect' }, { t: 'Path' }] });
|
||||
store.setPage('gone_b', k, 1, { objects: [{ t: 'IText' }] });
|
||||
|
||||
const orphans = store.orphanReport(['kept']);
|
||||
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
|
||||
assert.ok(orphans.every((o) => o.bytes > 0), '需报告体积供用户判断');
|
||||
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').count, 2);
|
||||
|
||||
// 仍在书库的条目绝不能出现在回收清单里
|
||||
assert.ok(!orphans.some((o) => o.entryId === 'kept'));
|
||||
|
||||
assert.strictEqual(store.forgetMany(orphans.map((o) => o.entryId)), 2);
|
||||
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'gone_a.json')), false);
|
||||
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'kept.json')), true);
|
||||
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||
assert.deepStrictEqual(store.orphanReport(['kept']), []);
|
||||
|
||||
// 传入非法 ID 不应中断其余回收
|
||||
store.setPage('gone_c', k, 1, { objects: [{ t: 'Rect' }] });
|
||||
assert.strictEqual(store.forgetMany(['../escape', 'gone_c']), 1);
|
||||
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||
});
|
||||
|
||||
test('forget 删除条目批注及备份残留', () => {
|
||||
const { store, dir } = fresh();
|
||||
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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 存储',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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\)/);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const adapterFile = path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs');
|
||||
const src = fs.readFileSync(adapterFile, 'utf8');
|
||||
|
||||
// pdf-adapter.mjs 直接 import vendor 的 pdf.min.mjs,在 Node 里加载会因缺 DOMMatrix 而抛错,
|
||||
// 所以只把不依赖 DOM 的钳制段落切出来求值,断言的仍是生产源码本身。
|
||||
function loadPureBlock() {
|
||||
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||
const end = src.indexOf('const CSS = `');
|
||||
assert.ok(start >= 0 && end > start, '找不到画布上限段落,钳制代码可能被移动或删除');
|
||||
const block = src.slice(start, end).replace(/^export /gm, '');
|
||||
assert.doesNotMatch(
|
||||
block,
|
||||
/\b(window|document|navigator)\b/,
|
||||
'钳制逻辑必须是纯函数,一旦依赖 DOM 就无法在单测里做真实数值断言'
|
||||
);
|
||||
return new Function(`${block}\nreturn { clampCanvasSize, clampRenderQuality };`)();
|
||||
}
|
||||
|
||||
const { clampCanvasSize, clampRenderQuality } = loadPureBlock();
|
||||
|
||||
const MAX_SIDE = 16384;
|
||||
const MAX_AREA = 268435456;
|
||||
|
||||
function assertWithinLimits(out, label) {
|
||||
assert.ok(
|
||||
out.width <= MAX_SIDE && out.height <= MAX_SIDE,
|
||||
`${label} 单边 ${out.width}x${out.height} 超过 ${MAX_SIDE},浏览器会静默给出不可用画布,整页空白且不报错`
|
||||
);
|
||||
assert.ok(
|
||||
out.width * out.height <= MAX_AREA,
|
||||
`${label} 面积 ${out.width * out.height} 超过 ${MAX_AREA},同样会静默失败`
|
||||
);
|
||||
assert.ok(
|
||||
out.width >= 1 && out.height >= 1,
|
||||
`${label} 钳制后出现 ${out.width}x${out.height},0 尺寸画布会让 getContext 之后的绘制全部丢弃`
|
||||
);
|
||||
}
|
||||
|
||||
test('A4 常规缩放与超采样不触发钳制', () => {
|
||||
// 612x792pt 的 A4 @ scale1.2,倍率 2:1468x1900,远在上限内
|
||||
const out = clampCanvasSize(612 * 1.2, 792 * 1.2, 2);
|
||||
assert.strictEqual(out.clamped, false, '常规页被误钳会白白牺牲清晰度');
|
||||
assert.strictEqual(out.quality, 2, '未触发上限时必须原样保留名义倍率');
|
||||
assert.strictEqual(out.width, 1468, `期望 1468 实际 ${out.width}`);
|
||||
assert.strictEqual(out.height, 1900, `期望 1900 实际 ${out.height}`);
|
||||
assertWithinLimits(out, 'A4@1.2x2');
|
||||
});
|
||||
|
||||
test('A4 最高缩放叠加 dpr3 仍不触发钳制', () => {
|
||||
// 实测 8929x12628(113M)是可用的,钳制不该把这一档也压下去
|
||||
const out = clampCanvasSize(612 * 5, 792 * 5, 3);
|
||||
assert.strictEqual(out.clamped, false, '这一档实测可用,钳制过度会让高缩放反而更糊');
|
||||
assert.strictEqual(out.width, 9180, `期望 9180 实际 ${out.width}`);
|
||||
assert.strictEqual(out.height, 11880, `期望 11880 实际 ${out.height}`);
|
||||
assertWithinLimits(out, 'A4@5x3');
|
||||
});
|
||||
|
||||
test('2000pt 大幅面 @ scale5 dpr2 被钳回上限内', () => {
|
||||
// 期望 backing 20000x14000(280M),实测不可用
|
||||
const out = clampCanvasSize(2000 * 5, 1400 * 5, 2);
|
||||
assert.strictEqual(out.clamped, true, '280M 画布必须被识别为超限,否则用户只看到空白页且没有任何提示');
|
||||
assertWithinLimits(out, '2000pt@5x2');
|
||||
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限以保留最大清晰度,实际 ${out.width}`);
|
||||
assert.ok(out.quality < 2, `生效倍率 ${out.quality} 应低于名义倍率 2`);
|
||||
});
|
||||
|
||||
test('4000pt 图纸 @ scale3 dpr2 被钳回上限内', () => {
|
||||
// 期望 backing 24000x18000(432M),实测不可用
|
||||
const out = clampCanvasSize(4000 * 3, 3000 * 3, 2);
|
||||
assert.strictEqual(out.clamped, true, '432M 画布必须被识别为超限');
|
||||
assertWithinLimits(out, '4000pt@3x2');
|
||||
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限,实际 ${out.width}`);
|
||||
assert.strictEqual(out.height, 12288, `期望 12288 实际 ${out.height}`);
|
||||
});
|
||||
|
||||
test('钳制后的实际比例如实报告,供 render 的 transform 使用', () => {
|
||||
const cssWidth = 4000 * 3;
|
||||
const cssHeight = 3000 * 3;
|
||||
const out = clampCanvasSize(cssWidth, cssHeight, 2);
|
||||
// transform 用名义倍率而不是取整后的实际比例,画面会错位或只画出一角
|
||||
assert.strictEqual(out.scaleX, out.width / cssWidth, 'scaleX 必须等于 backing 宽除以 CSS 宽');
|
||||
assert.strictEqual(out.scaleY, out.height / cssHeight, 'scaleY 必须等于 backing 高除以 CSS 高');
|
||||
assert.ok(
|
||||
Math.abs(out.scaleX - out.scaleY) < 1e-6,
|
||||
`等比钳制下 x/y 比例应基本一致,实际 ${out.scaleX} / ${out.scaleY}`
|
||||
);
|
||||
});
|
||||
|
||||
test('短边的取整损失也要反映到比例上,不能直接沿用生效倍率', () => {
|
||||
// 长边正好压到上限时它的比例恰等于生效倍率,只有短边能暴露 floor 带来的偏差
|
||||
const out = clampCanvasSize(16000, 17000, 1);
|
||||
assert.strictEqual(out.scaleX, out.width / 16000, 'scaleX 必须按 floor 之后的实际宽算');
|
||||
assert.notStrictEqual(
|
||||
out.scaleX,
|
||||
out.quality,
|
||||
`短边 ${out.width}px 的实际比例应当略小于生效倍率 ${out.quality},直接沿用会让内容画出画布边界`
|
||||
);
|
||||
assert.strictEqual(out.scaleY, out.height / 17000, 'scaleY 必须按 floor 之后的实际高算');
|
||||
});
|
||||
|
||||
test('取整误差不会让某一边掉到 0 像素', () => {
|
||||
const out = clampCanvasSize(100000, 1, 1);
|
||||
assertWithinLimits(out, '极端长条页');
|
||||
assert.strictEqual(out.height, 1, `期望至少 1px,实际 ${out.height}`);
|
||||
assert.strictEqual(out.scaleY, 1, 'scaleY 必须按补足到 1px 后的实际比例报告,否则内容会被压到画布外');
|
||||
});
|
||||
|
||||
test('长边超限而面积未超限的页也被钳', () => {
|
||||
// 20000x2000 = 40M,面积远未超限,只有单边超限
|
||||
const out = clampCanvasSize(20000, 2000, 1);
|
||||
assert.strictEqual(out.clamped, true, '只超单边同样不可用,不能只看面积');
|
||||
assertWithinLimits(out, '超长单边');
|
||||
assert.strictEqual(out.width, MAX_SIDE, `期望 ${MAX_SIDE} 实际 ${out.width}`);
|
||||
});
|
||||
|
||||
test('接近正方形的超大页同时压住面积与单边', () => {
|
||||
// 16000x17000 = 272M,面积超限且短边已经贴着上限,是最容易只压一头的形状
|
||||
const out = clampCanvasSize(16000, 17000, 1);
|
||||
assert.strictEqual(out.clamped, true, '272M 面积必须被识别为超限');
|
||||
assertWithinLimits(out, '16000x17000');
|
||||
assert.strictEqual(out.height, MAX_SIDE, `长边应压到上限,实际 ${out.height}`);
|
||||
});
|
||||
|
||||
test('面积约束是真在起作用,而不是被单边约束顺带盖住', () => {
|
||||
// 当前常量下 MAX_AREA 恰好等于 MAX_SIDE 的平方,压住长边就顺带压住了面积,
|
||||
// 面积检查的价值只有在上限常量按别的浏览器口径调整时才显现。
|
||||
// 这里把单边上限换成 65535 重新求值,确认删掉面积项会立刻放出 600M 的画布。
|
||||
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||
const end = src.indexOf('const CSS = `');
|
||||
const block = src.slice(start, end)
|
||||
.replace(/^export /gm, '')
|
||||
.replace('const MAX_CANVAS_SIDE = 16384;', 'const MAX_CANVAS_SIDE = 65535;');
|
||||
const wide = new Function(`${block}\nreturn clampCanvasSize;`)();
|
||||
const out = wide(30000, 20000, 1);
|
||||
assert.ok(
|
||||
out.width * out.height <= MAX_AREA,
|
||||
`面积 ${out.width * out.height} 超过 ${MAX_AREA}:面积约束没有独立生效,只靠单边约束挡不住扁平的大幅面页`
|
||||
);
|
||||
assert.strictEqual(out.clamped, true, '面积超限也必须如实上报,否则外壳无法解释清晰度为何被降');
|
||||
});
|
||||
|
||||
test('大范围尺寸与倍率组合下上限恒成立', () => {
|
||||
const sides = [1, 200, 612, 792, 1190, 2384, 5000, 10000, 20000, 40000];
|
||||
const qualities = [1, 1.25, 1.5, 2, 2.5, 3, 4];
|
||||
const scales = [0.25, 1, 1.2, 2, 3, 5];
|
||||
for (const w of sides) {
|
||||
for (const h of sides) {
|
||||
for (const q of qualities) {
|
||||
for (const s of scales) {
|
||||
const out = clampCanvasSize(w * s, h * s, q);
|
||||
const label = `${w}x${h} @scale${s} @${q}x`;
|
||||
assertWithinLimits(out, label);
|
||||
assert.ok(
|
||||
out.quality <= q + 1e-9,
|
||||
`${label}:生效倍率 ${out.quality} 不该超过名义倍率 ${q}`
|
||||
);
|
||||
assert.strictEqual(out.scaleX, out.width / (w * s), `${label}:scaleX 与实际 backing 宽不符`);
|
||||
assert.strictEqual(out.scaleY, out.height / (h * s), `${label}:scaleY 与实际 backing 高不符`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('非法尺寸与倍率退回安全值而不是抛错或产出 NaN', () => {
|
||||
for (const bad of [undefined, null, NaN, 0, -5, 'abc', Infinity]) {
|
||||
const out = clampCanvasSize(bad, bad, bad);
|
||||
assert.ok(
|
||||
Number.isInteger(out.width) && Number.isInteger(out.height),
|
||||
`尺寸 ${String(bad)} 产出了非整数 ${out.width}x${out.height},canvas.width 赋 NaN 会静默变 0`
|
||||
);
|
||||
assertWithinLimits(out, `非法输入 ${String(bad)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('renderQuality 只接受 1 到 4,非法值回退到 1', () => {
|
||||
assert.strictEqual(clampRenderQuality(1), 1);
|
||||
assert.strictEqual(clampRenderQuality(2), 2);
|
||||
assert.strictEqual(clampRenderQuality(4), 4);
|
||||
assert.strictEqual(clampRenderQuality(1.5), 1.5, '允许非整数档位,1.5x 实测已能显著降低误差');
|
||||
assert.strictEqual(clampRenderQuality(8), 4, '超过 4 倍换不来可感知的清晰度,只会成倍吃显存');
|
||||
assert.strictEqual(clampRenderQuality(0.5), 1, '低于 1 会比现状更糊');
|
||||
for (const bad of [undefined, null, NaN, 'abc', {}, Infinity, -Infinity]) {
|
||||
assert.strictEqual(
|
||||
clampRenderQuality(bad),
|
||||
1,
|
||||
`非法值 ${String(bad)} 必须回退到 1,回退到 NaN 会让整块 backing 计算失效`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('源码锁死画布上限常量与两条约束', () => {
|
||||
assert.match(src, /const MAX_CANVAS_SIDE = 16384;/, 'Chromium 单边上限,改动前必须先实测');
|
||||
assert.match(src, /const MAX_CANVAS_AREA = 268435456;/, 'Chromium 总面积上限');
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.min\(want, bySide, byArea\)/,
|
||||
'单边与面积两条约束都要参与,只留一条在上限常量变动后就会漏放超限画布'
|
||||
);
|
||||
assert.match(src, /MAX_CANVAS_SIDE \/ Math\.max\(cssWidth, cssHeight\)/, '单边约束按长边算');
|
||||
assert.match(src, /Math\.sqrt\(MAX_CANVAS_AREA \/ \(cssWidth \* cssHeight\)\)/, '面积约束按开方算');
|
||||
assert.match(src, /Math\.max\(1, Math\.floor\(cssWidth \* applied\)\)/, '钳制后至少保留 1px');
|
||||
assert.match(src, /Math\.max\(1, Math\.floor\(cssHeight \* applied\)\)/, '钳制后至少保留 1px');
|
||||
});
|
||||
|
||||
test('源码锁死超采样倍率的取值与生效方式', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.max\(1, Math\.min\(4, n\)\)/,
|
||||
'renderQuality 必须被 clamp 到 1..4'
|
||||
);
|
||||
// 一次锁死整条链路:dpr 取大而不是叠乘、期望倍率必须过钳制、画布尺寸只能来自钳制结果
|
||||
assert.match(
|
||||
src,
|
||||
/const dpr = window\.devicePixelRatio \|\| 1;\s*\n\s*const wanted = Math\.max\(dpr, clampRenderQuality\(renderQuality\)\);\s*\n\s*const fit = clampCanvasSize\(vp\.width, vp\.height, wanted\);\s*\n\s*p\.canvas\.width = fit\.width;\s*\n\s*p\.canvas\.height = fit\.height;/,
|
||||
'有效倍率必须是 max(dpr, renderQuality) 且画布尺寸只能取钳制结果,绕过任一步都会重新引入静默空白页'
|
||||
);
|
||||
assert.strictEqual(
|
||||
[...src.matchAll(/Math\.max\(dpr, clampRenderQuality\(renderQuality\)\)/g)].length,
|
||||
2,
|
||||
'renderPage 与 renderStats 必须用同一个有效倍率公式,否则界面报告的清晰度和实际渲染的不一致'
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/transform: identity \? null : \[fit\.scaleX, 0, 0, fit\.scaleY, 0, 0\]/,
|
||||
'transform 必须用钳制后的实际比例,用名义倍率会画错位或只画出一角'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/transform: dpr === 1 \? null : \[dpr, 0, 0, dpr, 0, 0\]/,
|
||||
'旧的 dpr 直接当 transform 的写法在钳制生效时会画错'
|
||||
);
|
||||
});
|
||||
|
||||
test('源码锁死倍率变化触发全页重建', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if \(nextScale !== scale \|\| nextQuality !== renderQuality\) \{\s*\n\s*epoch\+\+;/,
|
||||
'倍率变化不走 epoch++ 重建,同屏会残留旧清晰度的页'
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/nextQuality = opts && opts\.renderQuality !== undefined\s*\n?\s*\? clampRenderQuality\(opts\.renderQuality\)\s*\n?\s*: renderQuality/,
|
||||
'缺省 renderQuality 时必须沿用当前值,否则每次渲染都会把用户设置重置成 1'
|
||||
);
|
||||
assert.match(src, /renderStats\(\)/, 'renderStats 是外壳读取当前清晰度状态的唯一正当出口');
|
||||
assert.doesNotMatch(src, /window\.__test/, '不允许为测试往生产代码加全局钩子');
|
||||
});
|
||||
|
||||
test('AI 截图路径同样受画布上限保护', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const fit = clampCanvasSize\(area\.width, area\.height, wantScale\);/,
|
||||
'大幅面页在 renderScale 下限 1 时画布等于页面点尺寸,MediaBox 异常的文件会顶到上限'
|
||||
);
|
||||
assert.match(src, /const renderScale = fit\.quality;/, '截图的 viewport 与 transform 必须用钳制后的倍率');
|
||||
assert.match(src, /canvas\.width = fit\.width;/, '截图画布尺寸直接取钳制结果');
|
||||
// 图像只保留一条 JPEG 编码路径,钳制不该顺手引入格式回退
|
||||
assert.deepStrictEqual(
|
||||
[...src.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]),
|
||||
[],
|
||||
'编码仍应集中在 visual-context.mjs,适配器里不该出现新的编码路径'
|
||||
);
|
||||
});
|
||||
@@ -93,6 +93,34 @@ test('不同条目的阅读数据互相隔离', () => {
|
||||
assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
|
||||
});
|
||||
|
||||
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 } });
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
// TXT / Markdown 适配器单测。适配器本体是渲染层 ESM,这里用 jsdom 提供真实的
|
||||
// window / document / DOMParser,并加载仓库里真正会随包发布的 vendor 脚本,
|
||||
// 这样断言的是「真正交给 epub 适配器渲染的产物」,而不是桩。
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SRC = path.join(__dirname, '..', 'ui', 'reader', 'text-adapter.mjs');
|
||||
const VENDOR = path.join(__dirname, '..', 'ui', 'vendor');
|
||||
const XHTML = 'application/xhtml+xml';
|
||||
|
||||
function setupDom() {
|
||||
const { JSDOM } = require('jsdom');
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||
const win = dom.window;
|
||||
win.JSZip = require(path.join(VENDOR, 'jszip.min.js'));
|
||||
win.markdownit = require(path.join(VENDOR, 'markdown-it.min.js'));
|
||||
const purifyFactory = require(path.join(VENDOR, 'purify.min.js'));
|
||||
win.DOMPurify = typeof purifyFactory === 'function' ? purifyFactory(win) : purifyFactory;
|
||||
for (const key of ['window', 'document', 'DOMParser', 'XMLSerializer', 'NodeFilter', 'Node', 'Range']) {
|
||||
globalThis[key] = key === 'window' ? win : win[key];
|
||||
}
|
||||
return win;
|
||||
}
|
||||
|
||||
const win = setupDom();
|
||||
const mod = import('../ui/reader/text-adapter.mjs');
|
||||
|
||||
function u8(...parts) {
|
||||
const buffers = parts.map((part) => (typeof part === 'string'
|
||||
? Buffer.from(part, 'utf8')
|
||||
: Buffer.from(part)));
|
||||
return new Uint8Array(Buffer.concat(buffers));
|
||||
}
|
||||
|
||||
function utf16Bytes(text, littleEndian, bom) {
|
||||
const out = [];
|
||||
if (bom) out.push(...(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]));
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
const hi = code >> 8;
|
||||
const lo = code & 0xff;
|
||||
out.push(...(littleEndian ? [lo, hi] : [hi, lo]));
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
// tidy 与 epub 适配器里的同名函数一致,textOf 返回的是 tidy 之后的文本
|
||||
function tidy(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/\r/g, '')
|
||||
.replace(/[ \t\f\v\u00a0]+/g, ' ')
|
||||
.replace(/ ?\n ?/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function novel(sections) {
|
||||
const parts = [];
|
||||
for (let i = 1; i <= sections; i++) {
|
||||
parts.push(`第${i}章 标题${i}\n【标记${String(i).padStart(4, '0')}】这一节的正文内容,用来验证全文不缺不重。\n`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function readChapter(zipBytes, index) {
|
||||
const zip = await win.JSZip.loadAsync(zipBytes);
|
||||
const file = zip.file(`text/chapter-${index}.xhtml`);
|
||||
assert.ok(file, `产物里应有 text/chapter-${index}.xhtml`);
|
||||
return file.async('text');
|
||||
}
|
||||
|
||||
function parseXhtml(text) {
|
||||
const doc = new win.DOMParser().parseFromString(text, XHTML);
|
||||
assert.ok(doc && doc.body && !doc.querySelector('parsererror'),
|
||||
'生成的章节必须是合法 XHTML,否则 epub 适配器会退回容错 HTML 解析,行为不可预测');
|
||||
return doc;
|
||||
}
|
||||
|
||||
function auditUntrusted(doc, where) {
|
||||
for (const selector of ['script', 'iframe', 'object', 'embed', 'img', 'link', 'meta', 'form', 'base', 'a']) {
|
||||
assert.strictEqual(doc.querySelectorAll(selector).length, 0,
|
||||
`${where} 不允许出现 <${selector}>:这是不可信内容的注入面`);
|
||||
}
|
||||
for (const el of doc.querySelectorAll('*')) {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
assert.ok(!name.startsWith('on'),
|
||||
`${where} 出现事件属性 ${name},脚本会在阅读器里执行`);
|
||||
assert.ok(!['href', 'src', 'xlink:href', 'srcset', 'poster', 'style', 'formaction'].includes(name),
|
||||
`${where} 出现可发起加载或导航的属性 ${name}`);
|
||||
assert.ok(!/(?:javascript|vbscript|data|file):/i.test(attr.value),
|
||||
`${where} 属性 ${name} 里出现危险协议`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 编码检测与解码 --- */
|
||||
|
||||
test('UTF-8 与 UTF-16 的 BOM 都能正确剥离,BOM 不得留在正文里', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
const text = '中文标题\n正文第一行\n';
|
||||
|
||||
const plain = decodeTextBytes(u8(text));
|
||||
assert.strictEqual(plain.text, text, 'UTF-8 无 BOM 必须原样解出');
|
||||
assert.strictEqual(plain.encoding, 'utf-8');
|
||||
assert.strictEqual(plain.confident, true);
|
||||
|
||||
const withBom = decodeTextBytes(u8([0xef, 0xbb, 0xbf], text));
|
||||
assert.strictEqual(withBom.text, text, 'UTF-8 BOM 必须被剥掉,留着会变成正文首个不可见字符');
|
||||
assert.ok(!withBom.text.includes('\ufeff'), 'U+FEFF 不能出现在正文里');
|
||||
|
||||
const le = decodeTextBytes(utf16Bytes(text, true, true));
|
||||
assert.strictEqual(le.text, text, 'UTF-16LE 带 BOM 必须正确解出中文');
|
||||
assert.strictEqual(le.encoding, 'utf-16le');
|
||||
assert.ok(!le.text.includes('\ufeff'));
|
||||
|
||||
const be = decodeTextBytes(utf16Bytes(text, false, true));
|
||||
assert.strictEqual(be.text, text, 'UTF-16BE 带 BOM 必须正确解出中文');
|
||||
assert.strictEqual(be.encoding, 'utf-16be');
|
||||
assert.ok(!be.text.includes('\ufeff'));
|
||||
});
|
||||
|
||||
test('双 BOM 也要剥干净:TextDecoder 只吃掉第一个', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
// 某些编辑器另存为会叠一层 BOM。TextDecoder 只认最前面那个,第二个会变成正文首字符,
|
||||
// 章节标题匹配随即失效,目录莫名少一章。
|
||||
const text = '第1章 标题\n正文\n';
|
||||
assert.strictEqual(decodeTextBytes(u8([0xef, 0xbb, 0xbf], [0xef, 0xbb, 0xbf], text)).text, text);
|
||||
const le = utf16Bytes(text, true, true);
|
||||
assert.strictEqual(decodeTextBytes(u8([0xff, 0xfe], le)).text, text);
|
||||
});
|
||||
|
||||
test('无 BOM 的 UTF-16LE 靠 NUL 分布嗅探,不能当成乱码', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
const text = 'Chapter 1\nHello world, this is plain ASCII text stored as UTF-16LE.\n';
|
||||
const out = decodeTextBytes(utf16Bytes(text, true, false));
|
||||
assert.strictEqual(out.encoding, 'utf-16le');
|
||||
assert.strictEqual(out.text, text);
|
||||
});
|
||||
|
||||
test('GBK 字节走 gb18030 解码,不产生替换字符', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
// 「这是一段简体中文测试」的 GBK 编码
|
||||
const gbk = new Uint8Array([
|
||||
0xd5, 0xe2, 0xca, 0xc7, 0xd2, 0xbb, 0xb6, 0xce, 0xbc, 0xf2,
|
||||
0xcc, 0xe5, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4,
|
||||
0x0a
|
||||
]);
|
||||
const out = decodeTextBytes(gbk);
|
||||
assert.strictEqual(out.text, '这是一段简体中文测试\n', 'GBK 必须解成可读中文');
|
||||
assert.strictEqual(out.encoding, 'gb18030');
|
||||
assert.strictEqual(out.confident, true);
|
||||
assert.ok(!out.text.includes('\ufffd'), '解对了就不该有 U+FFFD');
|
||||
});
|
||||
|
||||
test('解不出任何像样编码时标记 confident=false,不让用户对着乱码猜', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
// 0x81 后跟 0x1f 在 GBK / Big5 里都是非法尾字节,cp1252 里则落进 C1 控制区
|
||||
const noise = new Uint8Array(512);
|
||||
for (let i = 0; i < noise.length; i++) noise[i] = i % 2 ? 0x1f : 0x81;
|
||||
const out = decodeTextBytes(noise);
|
||||
assert.strictEqual(out.confident, false, '低置信度必须上报,否则界面会静默显示乱码');
|
||||
|
||||
for (const [name, bytes] of [
|
||||
['UTF-8 中文', u8('正常的中文文本内容。\n')],
|
||||
['GBK 中文', new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0x0a])],
|
||||
['纯 ASCII', u8('Plain english text.\n')]
|
||||
]) {
|
||||
assert.strictEqual(decodeTextBytes(bytes).confident, true,
|
||||
`${name} 不能被误判成低置信度,否则提示会变成噪声,用户就不再看它了`);
|
||||
}
|
||||
});
|
||||
|
||||
test('打分只认确定是正文的字符,拉丁扩展区乱码一律不得正分', async () => {
|
||||
const { textScore, decodeTextBytes } = await mod;
|
||||
const chinese = '这是一段正常的简体中文正文内容。';
|
||||
const mojibake = Buffer.from(chinese, 'utf8').toString('latin1');
|
||||
assert.ok(textScore(chinese) > 0, 'ASCII 与中文正文必须是正分');
|
||||
assert.ok(textScore('Plain ASCII sentence.') > 0, 'ASCII 正文必须是正分');
|
||||
|
||||
// 乱码必须 <= 0 而不只是「低于正确解码」。一个汉字按 cp1252 摊成三个拉丁扩展字符,
|
||||
// 只要这些字符拿到任何正分,乱码就靠字符数优势翻盘:实测把 0xa0..0x24f 记 1 分后,
|
||||
// 同一段 GBK 正文的 windows-1252 得分从 0 涨到 320,与 gb18030 打平并靠顺序取胜。
|
||||
for (const [name, text] of [
|
||||
['UTF-8 中文按 cp1252 解出的乱码', mojibake],
|
||||
['纯拉丁扩展区噪声', new TextDecoder('windows-1252')
|
||||
.decode(new Uint8Array(Array.from({ length: 300 }, (_, i) => 0xa0 + (i % 0x40))))]
|
||||
]) {
|
||||
assert.ok(textScore(text) <= 0,
|
||||
`${name} 得分为 ${textScore(text)},必须 <= 0,否则乱码会冒充识别成功`);
|
||||
}
|
||||
|
||||
const gbk = new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4, 0x0a]);
|
||||
assert.strictEqual(decodeTextBytes(gbk).encoding, 'gb18030',
|
||||
'简体中文是本应用主场,不能被 windows-1252 的乱码抢走');
|
||||
});
|
||||
|
||||
test('空文件与超限文件都有确定行为', async () => {
|
||||
const { decodeTextBytes, MAX_TEXT_BYTES } = await mod;
|
||||
const empty = decodeTextBytes(new Uint8Array(0));
|
||||
assert.strictEqual(empty.text, '');
|
||||
assert.strictEqual(empty.confident, true);
|
||||
assert.throws(
|
||||
() => decodeTextBytes(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||
/超过 64 MB/,
|
||||
'超限必须抛中文错误,界面直接展示这句话'
|
||||
);
|
||||
});
|
||||
|
||||
test('CR LF 归一,C0 控制符被剔除', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
const out = decodeTextBytes(u8('第一行\r\n第二行\r第三行\u0000\u0007\n'));
|
||||
assert.strictEqual(out.text, '第一行\n第二行\n第三行\n',
|
||||
'C0 控制符留在正文会让生成的 XHTML 解析失败,正文随即退回容错解析');
|
||||
const tabbed = decodeTextBytes(u8('列一\t列二\n'));
|
||||
assert.strictEqual(tabbed.text, '列一\t列二\n', 'Tab 是合法 XML 字符,不能顺手删掉');
|
||||
});
|
||||
|
||||
/* --- 章节切分 --- */
|
||||
|
||||
test('TXT 按中文章节标题切分,边界落在标题行首', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const source = novel(6);
|
||||
const out = splitPlainText(source);
|
||||
assert.strictEqual(out.chapters.length, 6, '6 个「第N章」应切成 6 章');
|
||||
assert.deepStrictEqual(out.chapters.map((c) => c.label),
|
||||
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6'],
|
||||
'章节名必须取自正文里的标题行');
|
||||
assert.strictEqual(out.chapters[0].start, 0);
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source,
|
||||
'各章拼回来必须与原文逐字相同,否则全文一定缺字或重字');
|
||||
for (const chapter of out.chapters) {
|
||||
assert.strictEqual(source.slice(chapter.start, chapter.end), chapter.text, '偏移必须与切片一致');
|
||||
}
|
||||
});
|
||||
|
||||
test('第一个章节标题之前的正文单独成章,不能被丢掉', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const preamble = '书名:某本小说\n作者:某人\n版权声明若干。\n\n';
|
||||
const source = preamble + novel(4);
|
||||
const out = splitPlainText(source);
|
||||
assert.strictEqual(out.chapters.length, 5, '开头 + 4 章 = 5 章');
|
||||
assert.strictEqual(out.chapters[0].label, '开头');
|
||||
assert.strictEqual(out.chapters[0].text, preamble,
|
||||
'首个标题之前的内容必须完整保留,否则序言与版权页在全文里凭空消失');
|
||||
assert.strictEqual(out.chapters[1].label, '第1章 标题1');
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||
});
|
||||
|
||||
test('标题不足三个时不认,按长度分节,序号从 1 开始', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const source = `第1章 只有一个标题\n${'一二三四五六七八九十'.repeat(200)}\n`.repeat(1);
|
||||
const out = splitPlainText(source, { target: 500 });
|
||||
assert.ok(out.chapters.length >= 1);
|
||||
assert.match(out.chapters[0].label, /^第 1 节$/, '未识别到章节结构时用「第 N 节」');
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||
});
|
||||
|
||||
test('空文件、纯空白、单行超长都不崩且能拼回原文', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const empty = splitPlainText('');
|
||||
assert.strictEqual(empty.chapters.length, 1);
|
||||
assert.strictEqual(empty.chapters[0].text, '');
|
||||
assert.strictEqual(empty.chapters[0].label, '正文');
|
||||
|
||||
const blank = splitPlainText(' \n\n\t \n');
|
||||
assert.ok(blank.chapters.length >= 1);
|
||||
assert.strictEqual(blank.chapters.map((c) => c.text).join(''), ' \n\n\t \n');
|
||||
|
||||
const huge = 'x'.repeat(200000);
|
||||
const one = splitPlainText(huge, { target: 1000, max: 5000 });
|
||||
assert.ok(one.chapters.length > 1, '没有换行的超长文本也必须切开,否则单章会撑爆 DOM');
|
||||
assert.strictEqual(one.chapters.map((c) => c.text).join(''), huge);
|
||||
assert.ok(one.chapters.every((c) => c.text.length <= 5000), '每章不得超过上限');
|
||||
assert.ok(one.chapters.slice(1).every((c) => /(续)$/.test(c.label)), '硬切出来的后续片段要标注(续)');
|
||||
});
|
||||
|
||||
test('章节数不超过上限,避免打出上万个 zip 条目', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const source = novel(400);
|
||||
const out = splitPlainText(source, { maxChapters: 50 });
|
||||
assert.ok(out.chapters.length <= 50, `实际 ${out.chapters.length} 章,超过 maxChapters`);
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source, '合并章节不能丢正文');
|
||||
});
|
||||
|
||||
/* --- Markdown 结构 --- */
|
||||
|
||||
test('Markdown 标题生成正确的目录层级', async () => {
|
||||
const { createMarkdownRenderer, splitMarkdown, buildTocEntries, markdownHeadings } = await mod;
|
||||
const md = createMarkdownRenderer(win.markdownit);
|
||||
const source = [
|
||||
'# 总标题', '', '开场白。', '',
|
||||
'## 第一节', '', '内容一。', '',
|
||||
'### 一点一', '', '内容二。', '',
|
||||
'## 第二节', '', '内容三。', '',
|
||||
'```', '# 代码块里的井号不是标题', '```', ''
|
||||
].join('\n');
|
||||
|
||||
const headings = markdownHeadings(source, md);
|
||||
assert.deepStrictEqual(headings.map((h) => [h.level, h.label]), [
|
||||
[1, '总标题'], [2, '第一节'], [3, '一点一'], [2, '第二节']
|
||||
], '代码块里的 # 不能被当成标题');
|
||||
|
||||
const split = splitMarkdown(source, md);
|
||||
assert.deepStrictEqual(split.chapters.map((c) => c.label),
|
||||
['总标题', '第一节', '第二节'], '默认按 h1/h2 切章,h3 留在章内');
|
||||
assert.strictEqual(split.title, '总标题');
|
||||
assert.strictEqual(split.chapters.map((c) => c.text).join(''), source);
|
||||
|
||||
const { entries, anchorsByChapter } = buildTocEntries(split.chapters, split.headings, split.splitLevel);
|
||||
assert.deepStrictEqual(entries.map((e) => [e.label, e.depth, e.chapter]), [
|
||||
['总标题', 0, 0], ['第一节', 1, 1], ['一点一', 2, 1], ['第二节', 1, 2]
|
||||
], 'depth 必须等于标题级别减一,目录才能正确缩进');
|
||||
assert.strictEqual(anchorsByChapter.get(1)[0], '', '章首标题跳章即可,不需要锚点');
|
||||
assert.ok(anchorsByChapter.get(1)[1], '章内标题必须有锚点,否则目录只能跳到章首');
|
||||
});
|
||||
|
||||
test('锚点数量与渲染出的标题数不符时不打 id,避免锚点指到错误标题', async () => {
|
||||
const { createMarkdownRenderer, markdownChapterXhtml, resolveServices } = await mod;
|
||||
const md = createMarkdownRenderer(win.markdownit);
|
||||
const services = resolveServices();
|
||||
const chapter = { label: '甲', text: '# 一\n\n正文\n\n## 二\n', start: 0, end: 20 };
|
||||
|
||||
const mismatched = markdownChapterXhtml(chapter, services, md, ['only-one']);
|
||||
assert.ok(!/\bid="/.test(mismatched),
|
||||
'章节边界可能落进代码围栏,使章内真实标题数与预估不符;此时按序号打 id 会让目录跳到错误的标题,宁可放弃锚点');
|
||||
assert.strictEqual(parseXhtml(mismatched).querySelectorAll('h1,h2').length, 2, '放弃锚点不等于放弃正文');
|
||||
|
||||
const matched = markdownChapterXhtml(chapter, services, md, ['a1', 'a2']);
|
||||
const ids = Array.from(parseXhtml(matched).querySelectorAll('[id]')).map((el) => el.getAttribute('id'));
|
||||
assert.deepStrictEqual(ids, ['a1', 'a2'], '数量对得上时必须按文档顺序打 id');
|
||||
});
|
||||
|
||||
test('没有 h1/h2 的 Markdown 回退到按长度分节,标题列表仍然可用', async () => {
|
||||
const { createMarkdownRenderer, splitMarkdown } = await mod;
|
||||
const md = createMarkdownRenderer(win.markdownit);
|
||||
const source = `### 只有三级标题\n\n${'正文内容。'.repeat(50)}\n`;
|
||||
const out = splitMarkdown(source, md);
|
||||
assert.ok(out.chapters.length >= 1, '无 h1/h2 也要有章节');
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||
assert.strictEqual(out.headings.length, 1, 'h3 仍应出现在目录里');
|
||||
assert.strictEqual(out.title, '只有三级标题');
|
||||
|
||||
const bare = splitMarkdown('只有一行正文,没有任何标题。', md);
|
||||
assert.strictEqual(bare.chapters.length, 1);
|
||||
assert.ok(bare.chapters[0].label, '无标题时也要有可展示的章节名');
|
||||
});
|
||||
|
||||
test('目录嵌套标记闭合正确,epub 目录解析器按 li > ol 递归', async () => {
|
||||
const { navMarkup } = await mod;
|
||||
const html = navMarkup([
|
||||
{ label: 'A', depth: 0, href: 'a' },
|
||||
{ label: 'A1', depth: 1, href: 'a1' },
|
||||
{ label: 'A2', depth: 1, href: 'a2' },
|
||||
{ label: 'B', depth: 0, href: 'b' }
|
||||
]);
|
||||
assert.strictEqual(html,
|
||||
'<ol><li><a href="a">A</a><ol><li><a href="a1">A1</a></li><li><a href="a2">A2</a></li></ol></li><li><a href="b">B</a></li></ol>');
|
||||
const doc = new win.DOMParser().parseFromString(`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||
assert.ok(!doc.querySelector('parsererror'), '目录标记必须是合法 XHTML');
|
||||
assert.strictEqual(doc.querySelectorAll('li').length, 4);
|
||||
assert.strictEqual(doc.querySelectorAll('li > ol > li').length, 2, '子级必须挂在父级 li 里');
|
||||
|
||||
const skipped = navMarkup([{ label: 'X', depth: 3, href: 'x' }]);
|
||||
assert.strictEqual(skipped, '<ol><li><a href="x">X</a></li></ol>', '首条深度跳跃要收敛到顶层,不能生成孤立 ol');
|
||||
});
|
||||
|
||||
test('任意 depth 序列生成的目录都是闭合的合法 XHTML,且条目一个不少', async () => {
|
||||
const { navMarkup } = await mod;
|
||||
const cases = [
|
||||
[0], [1], [0, 1, 2, 3, 2, 1, 0], [2, 0, 2], [0, 0, 0],
|
||||
[0, 2, 1, 3, 0], [1, 1, 0, 5, 0], [3, 3, 3], []
|
||||
];
|
||||
for (const depths of cases) {
|
||||
const entries = depths.map((depth, i) => ({ label: `L${i}`, depth, href: `h${i}` }));
|
||||
const html = navMarkup(entries);
|
||||
const doc = new win.DOMParser().parseFromString(
|
||||
`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||
assert.ok(!doc.querySelector('parsererror'),
|
||||
`depth 序列 [${depths}] 生成了非法 XHTML:${html};标记不闭合会让整个 nav.xhtml 解析失败,目录直接退化成按章编号`);
|
||||
assert.strictEqual(doc.querySelectorAll('li').length, depths.length,
|
||||
`depth 序列 [${depths}] 的目录条目数应为 ${depths.length}`);
|
||||
assert.strictEqual(doc.querySelectorAll('ol > li').length, depths.length,
|
||||
`depth 序列 [${depths}] 里每个 li 都必须直接挂在 ol 下`);
|
||||
}
|
||||
});
|
||||
|
||||
/* --- 净化 --- */
|
||||
|
||||
const EVIL_MARKDOWN = [
|
||||
'# 标题',
|
||||
'',
|
||||
'<script>alert(1)</script>',
|
||||
'',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'',
|
||||
'[链接](javascript:alert(1))',
|
||||
'',
|
||||
'<iframe src="http://evil.example"></iframe>',
|
||||
'',
|
||||
'<div onmouseover="alert(1)">悬停</div>',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'[正常外链](https://example.com/page)',
|
||||
'',
|
||||
'<svg><use xlink:href="http://evil.example/x#a" /></svg>',
|
||||
''
|
||||
].join('\n');
|
||||
|
||||
test('sanitizeMarkdownFragment 直接吃裸 HTML 也必须净化干净', async () => {
|
||||
const { sanitizeMarkdownFragment, resolveServices } = await mod;
|
||||
const services = resolveServices();
|
||||
const fragment = sanitizeMarkdownFragment([
|
||||
'<h1 id="x">标题</h1>',
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<iframe src="http://evil.example"></iframe>',
|
||||
'<a href="javascript:alert(1)">链接</a>',
|
||||
'<p style="background:url(http://evil.example/x)">样式</p>',
|
||||
'<object data="x"></object><embed src="x" /><form action="x"></form>',
|
||||
'<p onclick="alert(1)">点我</p>'
|
||||
].join(''), services);
|
||||
const host = win.document.createElement('div');
|
||||
host.appendChild(fragment);
|
||||
const doc = parseXhtml(`<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head><body>${new win.XMLSerializer().serializeToString(host)}</body></html>`);
|
||||
auditUntrusted(doc, '净化后的片段');
|
||||
assert.ok(doc.body.textContent.includes('标题'), '净化不能把正常正文一起吃掉');
|
||||
});
|
||||
|
||||
test('恶意 Markdown 渲染出的章节里没有可执行内容', async () => {
|
||||
const { buildTextEpub } = await mod;
|
||||
const built = await buildTextEpub(u8(EVIL_MARKDOWN), { format: 'md' });
|
||||
const xhtml = await readChapter(built.bytes, 0);
|
||||
const doc = parseXhtml(xhtml);
|
||||
auditUntrusted(doc, 'Markdown 章节');
|
||||
|
||||
const text = doc.body.textContent;
|
||||
assert.ok(text.includes('alert(1)'),
|
||||
'危险内容应被转义成可见文字而不是静默删除,用户才知道原文写了什么');
|
||||
assert.ok(text.includes('图片:图注'), '图片必须换成占位符,绝不外链加载');
|
||||
assert.ok(text.includes('正常外链'), '外链文字要保留,只是不再可点');
|
||||
assert.ok(!/<script|<iframe|<img|<svg/i.test(xhtml), '序列化结果里不允许出现这些标签');
|
||||
});
|
||||
|
||||
test('源码锁死两道防线:markdown-it html:false 与放行名单里没有 href/src', async () => {
|
||||
const src = fs.readFileSync(SRC, 'utf8');
|
||||
assert.match(src, /markdownit\(\{[\s\S]{0,400}?html: false/,
|
||||
'html:false 是第一道防线,去掉后裸 HTML 会直接进入渲染管线');
|
||||
const allowed = src.match(/const ALLOWED_ATTR = Object\.freeze\(\[([^\]]*)\]\)/);
|
||||
assert.ok(allowed, '必须显式声明放行属性名单');
|
||||
assert.ok(!/href|src|style|srcset|on[a-z]/i.test(allowed[1]),
|
||||
`放行名单不能含加载或导航类属性,当前为 ${allowed[1]}`);
|
||||
assert.match(src, /RETURN_DOM_FRAGMENT: true/, '必须以片段形式取回净化结果,避免二次解析引入 mXSS');
|
||||
});
|
||||
|
||||
/* --- 端到端:契约方法 --- */
|
||||
|
||||
test('TXT 走完整管线:章节数、目录、locator kind 与全文', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const source = novel(6);
|
||||
const adapter = createTextAdapter('txt');
|
||||
const progress = [];
|
||||
try {
|
||||
const info = await adapter.load(u8(source), { onProgress: (p) => progress.push(p) });
|
||||
assert.strictEqual(info.format, 'txt');
|
||||
assert.strictEqual(info.mode, 'plain');
|
||||
assert.strictEqual(info.encoding, 'utf-8');
|
||||
assert.strictEqual(info.chapterCount, 6);
|
||||
assert.strictEqual(info.title, '未命名文本',
|
||||
'首行本身是章节标题时不能拿来当书名,否则书库标题会变成「第1章」');
|
||||
assert.ok(progress.length && progress[progress.length - 1] === 1, 'onProgress 必须走到 1');
|
||||
assert.ok(progress.every((p) => p >= 0 && p <= 1), '进度必须落在 0..1');
|
||||
|
||||
const toc = await adapter.toc();
|
||||
assert.strictEqual(toc.length, 6);
|
||||
assert.deepStrictEqual(toc.map((t) => t.label),
|
||||
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6']);
|
||||
assert.deepStrictEqual(toc.map((t) => t.locator.chapter), [0, 1, 2, 3, 4, 5]);
|
||||
for (const entry of toc) {
|
||||
assert.strictEqual(entry.locator.kind, 'txt', 'locator.kind 必须是 txt,外壳靠它判断书签与笔记');
|
||||
assert.strictEqual(entry.depth, 0);
|
||||
}
|
||||
|
||||
const label = adapter.locatorLabel({ kind: 'txt', chapter: 2, offset: 0 });
|
||||
assert.strictEqual(label, '第3章 标题3');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('首行是书名时取作标题,是章节标题时不取', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
assert.strictEqual(splitPlainText(`某本小说的书名\n\n${novel(4)}`).title, '某本小说的书名');
|
||||
assert.strictEqual(splitPlainText(novel(4)).title, '',
|
||||
'首行是「第1章」时留空,交由上层用文件名兜底');
|
||||
});
|
||||
|
||||
test("textOf(locator, 'document') 返回全文,不缺不重", async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const source = novel(8);
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(source));
|
||||
const full = await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||
assert.strictEqual(full, tidy(source),
|
||||
'AI 的「全文」范围完全依赖这条,少一个字就是静默数据丢失');
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const token = `【标记${String(i).padStart(4, '0')}】`;
|
||||
assert.strictEqual(full.split(token).length - 1, 1, `${token} 必须恰好出现一次`);
|
||||
}
|
||||
const positions = [];
|
||||
for (let i = 1; i <= 8; i++) positions.push(full.indexOf(`【标记${String(i).padStart(4, '0')}】`));
|
||||
assert.deepStrictEqual(positions, [...positions].sort((a, b) => a - b), '全文顺序必须与原文一致');
|
||||
|
||||
const one = await adapter.textOf({ kind: 'txt', chapter: 3, offset: 0 }, 'chapter');
|
||||
assert.ok(one.includes('【标记0004】'), '按章取文必须取到对应章');
|
||||
assert.ok(!one.includes('【标记0005】'), '按章取文不得越界');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Markdown 走完整管线:标题渲染成 h1/h2,代码块与表格保留', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const source = [
|
||||
'# 文档标题', '', '开场白。', '',
|
||||
'## 列表与代码', '',
|
||||
'- 第一项', '- 第二项', '',
|
||||
'```js', 'const x = 1;', '```', '',
|
||||
'> 引用一句话', '',
|
||||
'| 列一 | 列二 |', '| --- | --- |', '| 1 | 2 |', '',
|
||||
'**加粗**与 `行内代码`。', ''
|
||||
].join('\n');
|
||||
const adapter = createTextAdapter('md');
|
||||
try {
|
||||
const info = await adapter.load(u8(source));
|
||||
assert.strictEqual(info.format, 'md');
|
||||
assert.strictEqual(info.mode, 'markdown');
|
||||
assert.strictEqual(info.title, '文档标题');
|
||||
assert.strictEqual(info.chapterCount, 2);
|
||||
|
||||
const toc = await adapter.toc();
|
||||
assert.deepStrictEqual(toc.map((t) => [t.label, t.depth]), [['文档标题', 0], ['列表与代码', 1]]);
|
||||
assert.strictEqual(toc[0].locator.kind, 'md', 'Markdown 的 locator.kind 必须是 md');
|
||||
|
||||
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||
for (const piece of ['文档标题', '开场白。', '第一项', '第二项', 'const x = 1;', '引用一句话', '列一', '加粗', '行内代码']) {
|
||||
assert.ok(full.includes(piece), `全文里必须有「${piece}」,Markdown 渲染不能吞内容`);
|
||||
}
|
||||
assert.ok(!full.includes('```'), 'Markdown 记号应被渲染掉而不是原样留在正文里');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('渲染后的块级元素在全文里彼此分行,AI 不会拿到糊成一团的文本', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('md');
|
||||
try {
|
||||
// 标题与紧随其后的正文之间原文没有空行,最容易被拼成「标题甲正文乙」
|
||||
await adapter.load(u8('# 标题甲\n正文乙\n\n## 标题丙\n正文丁\n\n- 列表戊\n- 列表己\n'));
|
||||
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||
for (const [a, b] of [['标题甲', '正文乙'], ['标题丙', '正文丁'], ['列表戊', '列表己']]) {
|
||||
assert.ok(!full.includes(a + b),
|
||||
`「${a}」与「${b}」被拼成了一个词,块级元素之间必须留分隔符,否则送给模型的全文语义错乱`);
|
||||
assert.ok(new RegExp(`${a}\\n+${b}`).test(full), `「${a}」与「${b}」之间应有换行`);
|
||||
}
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Markdown 章节渲染成真正的块级结构,不是纯文本', async () => {
|
||||
const { buildTextEpub } = await mod;
|
||||
const built = await buildTextEpub(u8([
|
||||
'# 标题', '', '- 项', '', '```js', 'const x = 1;', '```', '',
|
||||
'> 引用', '', '| a | b |', '| - | - |', '| 1 | 2 |', ''
|
||||
].join('\n')), { format: 'md' });
|
||||
const doc = parseXhtml(await readChapter(built.bytes, 0));
|
||||
assert.strictEqual(doc.querySelectorAll('h1').length, 1, 'Markdown 标题必须渲染成 h1');
|
||||
assert.strictEqual(doc.querySelectorAll('ul > li').length, 1);
|
||||
assert.strictEqual(doc.querySelectorAll('pre > code').length, 1);
|
||||
assert.strictEqual(doc.querySelectorAll('blockquote').length, 1);
|
||||
assert.strictEqual(doc.querySelectorAll('table td').length, 2);
|
||||
});
|
||||
|
||||
test('超大 Markdown 降级为纯文本,不让 markdown-it 整篇解析', async () => {
|
||||
const { buildTextEpub, MARKDOWN_MAX_CHARS } = await mod;
|
||||
const over = `# 标题\n\n${'abcde\n'.repeat(Math.ceil(MARKDOWN_MAX_CHARS / 6) + 100)}`;
|
||||
assert.ok(over.length > MARKDOWN_MAX_CHARS);
|
||||
const built = await buildTextEpub(u8(over), { format: 'md' });
|
||||
assert.strictEqual(built.mode, 'plain', '超限时必须降级,否则整篇解析的开销与内存都不可控');
|
||||
const under = await buildTextEpub(u8('# 标题\n\n正文。\n'), { format: 'md' });
|
||||
assert.strictEqual(under.mode, 'markdown', '正常体量的 md 必须走渲染路径');
|
||||
});
|
||||
|
||||
test('几十 MB 的 txt 切成小章,单章体积可控', async () => {
|
||||
const { buildTextEpub, splitPlainText } = await mod;
|
||||
const block = `第N章 标题\n${'这一段是压力测试用的中文正文。'.repeat(10)}\n\n`;
|
||||
const source = block.repeat(2000).replace(/第N章/g, () => '第1章');
|
||||
const split = splitPlainText(source);
|
||||
assert.ok(split.chapters.length <= 4000, '章节数必须有上限,否则 zip 条目数失控');
|
||||
assert.ok(split.chapters.every((c) => c.text.length <= 48000),
|
||||
'单章必须足够小,一次性把整份文本塞进 DOM 会卡死渲染进程');
|
||||
const built = await buildTextEpub(u8(source), { format: 'txt' });
|
||||
assert.strictEqual(built.chapterCount, split.chapters.length);
|
||||
assert.strictEqual(built.mode, 'plain');
|
||||
});
|
||||
|
||||
test('nextLocator / prevLocator 在两端返回 null', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(novel(4)));
|
||||
assert.strictEqual(adapter.prevLocator({ kind: 'txt', chapter: 0, offset: 0 }), null,
|
||||
'第一章没有上一章,返回 null 外壳才会禁用按钮');
|
||||
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 3, offset: 0 }), null,
|
||||
'最后一章没有下一章');
|
||||
const next = adapter.nextLocator({ kind: 'txt', chapter: 0, offset: 500 });
|
||||
assert.deepStrictEqual(next, { kind: 'txt', chapter: 1, offset: 0 });
|
||||
const prev = adapter.prevLocator({ kind: 'txt', chapter: 2, offset: 10 });
|
||||
assert.deepStrictEqual(prev, { kind: 'txt', chapter: 1, offset: 0 });
|
||||
assert.deepStrictEqual(adapter.nextLocator(null), { kind: 'txt', chapter: 1, offset: 0 },
|
||||
'非法 locator 要按第 0 章处理,不能抛');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('percentOf 与 locatorFromPercent 互为逆运算', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(novel(8)));
|
||||
// 章内百分比需要章节长度,先取一次全文把长度缓存起来(外壳打开书后同样会发生)
|
||||
await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 0, offset: 0 }), 0);
|
||||
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 7, offset: 1e9 }), 1);
|
||||
for (const p of [0, 0.125, 0.3, 0.5, 0.77, 1]) {
|
||||
const locator = adapter.locatorFromPercent(p);
|
||||
assert.strictEqual(locator.kind, 'txt');
|
||||
const back = adapter.percentOf(locator);
|
||||
assert.ok(Math.abs(back - p) < 0.02,
|
||||
`百分比往返偏差过大:${p} -> ${JSON.stringify(locator)} -> ${back};进度条与书签会漂移`);
|
||||
}
|
||||
assert.deepStrictEqual(adapter.locatorFromPercent(-5), { kind: 'txt', chapter: 0, offset: 0 },
|
||||
'越界百分比必须夹紧');
|
||||
assert.strictEqual(adapter.locatorFromPercent(99).chapter, 7);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('超过体积上限时 load 抛中文错误', async () => {
|
||||
const { createTextAdapter, MAX_TEXT_BYTES } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => adapter.load(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||
/超过 64 MB/,
|
||||
'超限错误会原样显示给用户,必须是中文'
|
||||
);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('适配器暴露外壳依赖的全部方法,且 .md 后缀归一', async () => {
|
||||
const { createTextAdapter, normalizeTextFormat } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
for (const name of [
|
||||
'load', 'renderTo', 'toc', 'getSelection', 'textOf', 'locatorLabel',
|
||||
'nextLocator', 'prevLocator', 'percentOf', 'locatorFromPercent',
|
||||
'capturePinchAnchor', 'restorePinchAnchor', 'setLocatorChangeHandler',
|
||||
'setTouchGestureHandler', 'visualViewportRect', 'destroy'
|
||||
]) {
|
||||
assert.strictEqual(typeof adapter[name], 'function', `外壳会调用 ${name},必须实现`);
|
||||
}
|
||||
assert.strictEqual(adapter.getSelection(), null, '未渲染时取选区应返回 null 而不是抛错');
|
||||
assert.strictEqual(adapter.visualViewportRect(), null);
|
||||
assert.strictEqual(adapter.capturePinchAnchor(0, 0), null);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
assert.strictEqual(normalizeTextFormat('.MD'), 'md');
|
||||
assert.strictEqual(normalizeTextFormat('markdown'), 'md');
|
||||
assert.strictEqual(normalizeTextFormat('txt'), 'txt');
|
||||
assert.strictEqual(normalizeTextFormat(undefined), 'txt');
|
||||
});
|
||||
|
||||
test('destroy 之后再次 load 不残留上一本书的状态', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(novel(6)));
|
||||
assert.strictEqual((await adapter.toc()).length, 6);
|
||||
adapter.destroy();
|
||||
assert.strictEqual(adapter.documentInfo(), null, 'destroy 必须清掉文档信息');
|
||||
await adapter.load(u8(novel(3)));
|
||||
assert.strictEqual((await adapter.toc()).length, 3, '重新 load 后目录必须是新书的');
|
||||
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 2, offset: 0 }), null);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
+351
-6
@@ -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 消息的 contextRef,quote 取那条问题文本
|
||||
assert.match(shell, /if \(aiThread\[i\]\.role === 'user'\) \{ ask = aiThread\[i\]; break; \}/);
|
||||
assert.match(shell, /locator: \(ref && ref\.locator\) \|\| null/);
|
||||
assert.match(shell, /quote: ask \? String\(ask\.text \|\| ''\) : ''/);
|
||||
assert.match(shell, /async function saveAiNote\(target\)/);
|
||||
});
|
||||
|
||||
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
|
||||
const 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"/);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 元数据落盘的统一入口。
|
||||
//
|
||||
// rename 只保证目录项替换是原子的,不保证被替换的数据已经到磁盘:
|
||||
// 断电后可能出现"改名成功但文件内容是一段空洞"的结果,长度正常、内容全零。
|
||||
// 因此改名前必须先 fsync 数据本身,改名后再 fsync 目录让目录项落盘。
|
||||
// Windows 不允许对目录取句柄,那一步失败时忽略即可。
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function writeSynced(file, data, encoding = 'utf8') {
|
||||
const fd = fs.openSync(file, 'w');
|
||||
try {
|
||||
fs.writeFileSync(fd, data, encoding === null ? undefined : { encoding });
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function syncDirectory(dir) {
|
||||
let fd = null;
|
||||
try {
|
||||
fd = fs.openSync(dir, 'r');
|
||||
fs.fsyncSync(fd);
|
||||
} catch (e) {
|
||||
// Windows 无法 fsync 目录;其它平台失败也不该让写入整体失败
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 dest,保留一份 .bak 以便下次读取时恢复。
|
||||
// 失败时清理临时文件,并在目标已被改走时把备份换回去。
|
||||
function writeJson(dest, value) {
|
||||
const temp = `${dest}.tmp`;
|
||||
const backup = `${dest}.bak`;
|
||||
let backedUp = false;
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
try {
|
||||
writeSynced(temp, JSON.stringify(value, null, 2));
|
||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.renameSync(dest, backup);
|
||||
backedUp = true;
|
||||
}
|
||||
fs.renameSync(temp, dest);
|
||||
syncDirectory(path.dirname(dest));
|
||||
if (backedUp) {
|
||||
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响写入结果 */ }
|
||||
}
|
||||
} catch (e) {
|
||||
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
|
||||
try {
|
||||
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
||||
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
|
||||
function writeBytesExclusive(dest, bytes) {
|
||||
const temp = `${dest}.tmp`;
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
const fd = fs.openSync(temp, 'wx');
|
||||
try {
|
||||
fs.writeFileSync(fd, bytes);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(temp, dest);
|
||||
syncDirectory(path.dirname(dest));
|
||||
}
|
||||
|
||||
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive };
|
||||
@@ -8,6 +8,7 @@ const BOOK_EXT = new Set([
|
||||
'azw',
|
||||
'azw3',
|
||||
'txt',
|
||||
'md',
|
||||
'djvu',
|
||||
'fb2',
|
||||
'cbz',
|
||||
|
||||
+87
-35
@@ -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
@@ -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 不接受 assistant,output_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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 与损坏文件可改用系统应用打开。
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/* 笔记独立窗口。整窗给编辑区,画布不再受模态尺寸限制。 */
|
||||
|
||||
.note-window-body {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 标签条与阅读器同构。note.html 不加载 reader.css,所以这里要自带一份,
|
||||
class 名保持一致,改样式时两处要一起改。 */
|
||||
.doctabs {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-soft);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.doctabs-list {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.doctabs-list::-webkit-scrollbar { height: 0; }
|
||||
.doctab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
flex-shrink: 0;
|
||||
margin: 4px 0;
|
||||
padding: 0 8px 0 14px;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* --hover-bg-soft 只在 reader.css 里定义,这里必须用 style.css 有的变量,
|
||||
否则 hover 背景静默失效 */
|
||||
.doctab:hover { background: var(--hover-bg); color: var(--text); }
|
||||
.doctab.active { background: var(--bg-card); border-color: var(--line); color: var(--text); }
|
||||
.doctab-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.doctab.active .doctab-name { color: var(--accent-bright); font-weight: 600; }
|
||||
.doctab-fmt {
|
||||
padding: 0 5px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
/* 未保存标记:标签上必须看得见,否则关窗时才发现有改动 */
|
||||
.doctab-dirty {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-bright);
|
||||
}
|
||||
.doctab-close {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doctab-close:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
/* 每个标签一个视图,非活跃的整块隐藏。用 display:none 而不是移出 DOM:
|
||||
编辑器实例要留着,切回来不必重建,也不会丢未保存内容。 */
|
||||
.note-tab-views {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.note-tab-view {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.note-tab-view.inactive { display: none; }
|
||||
|
||||
.note-dirty-box { width: 420px; max-width: 92vw; }
|
||||
|
||||
.note-dirty-notice {
|
||||
margin: 0 0 4px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.note-window {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 14px 18px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.note-window-loading,
|
||||
.note-window-error {
|
||||
padding: 24px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note-window-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.note-window-form {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.note-window-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.note-window-title {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.note-window-title:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.note-window-badge {
|
||||
padding: 4px 10px;
|
||||
flex: 0 0 auto;
|
||||
background: var(--hover-strong);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 编辑区吃满剩余空间。min-height:0 缺一层,flex 子项就会被内容顶高、画布溢出窗口 */
|
||||
.note-window-editor {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-window-editor > .mixed-note-editor,
|
||||
.note-window-editor .mixed-note-canvas,
|
||||
.note-window-editor .mixed-note-text,
|
||||
.note-window-editor .rich-note-editor {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-window-editor .ql-container {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.note-window-quote {
|
||||
max-height: 88px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
flex: 0 0 auto;
|
||||
overflow-y: auto;
|
||||
background: var(--hover);
|
||||
color: var(--text-dim);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note-window-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.note-window-meta label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 笔记本名过长会把整行顶宽,须同时限宽并允许收缩到 0 */
|
||||
.note-window-meta select,
|
||||
.note-window-meta input[type="text"] {
|
||||
height: 28px;
|
||||
max-width: 260px;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 8px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note-window-meta select:focus,
|
||||
.note-window-meta input[type="text"]:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.note-window-tags input { width: 220px; }
|
||||
|
||||
.note-window-pin { cursor: pointer; }
|
||||
|
||||
.note-window-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.note-window-status {
|
||||
flex: 1;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note-window-status.error { color: var(--danger); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.note-window { padding: 10px 12px 12px; }
|
||||
.note-window-tags input { width: 140px; }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-ui-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; script-src 'self'" />
|
||||
<title>PeopleLib</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="vendor/quill/quill.snow.css" />
|
||||
<link rel="stylesheet" href="rich-note.css" />
|
||||
<link rel="stylesheet" href="note-window.css" />
|
||||
</head>
|
||||
<body class="note-window-body">
|
||||
<div class="titlebar">
|
||||
<div class="titlebar-left">
|
||||
<span class="brand">
|
||||
<img class="brand-logo brand-logo-dark" src="../../icons/dist/dark/icon-32.png" alt="" />
|
||||
<img class="brand-logo brand-logo-light" src="../../icons/dist/light/icon-32.png" alt="" />
|
||||
<span>笔记</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="titlebar-spacer"></div>
|
||||
<div class="titlebar-controls">
|
||||
<button id="uiThemeBtn" class="win-btn ui-theme-btn" title="切换到明亮主题" aria-label="切换到明亮主题">
|
||||
<svg class="titlebar-icon ui-theme-sun" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
|
||||
</svg>
|
||||
<svg class="titlebar-icon ui-theme-moon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="minBtn" class="win-btn" title="最小化">─</button>
|
||||
<button id="maxBtn" class="win-btn" title="最大化">□</button>
|
||||
<button id="closeBtn" class="win-btn win-close" title="关闭">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doctabs">
|
||||
<div id="noteTabsList" class="doctabs-list" role="tablist"></div>
|
||||
</div>
|
||||
|
||||
<main class="note-window">
|
||||
<div id="noteWindowLoading" class="note-window-loading">正在加载笔记…</div>
|
||||
|
||||
<div id="noteWindowError" class="note-window-error hidden" role="alert"></div>
|
||||
|
||||
<div id="noteWindowEmpty" class="note-window-loading hidden">没有打开的笔记</div>
|
||||
|
||||
<div id="noteTabViews" class="note-tab-views"></div>
|
||||
</main>
|
||||
|
||||
<template id="noteTabTemplate">
|
||||
<form class="note-window-form" autocomplete="off">
|
||||
<div class="note-window-head">
|
||||
<input class="note-window-title" type="text" maxlength="300" placeholder="标题(可选)" />
|
||||
<span class="note-window-badge"></span>
|
||||
</div>
|
||||
|
||||
<div class="note-window-editor"></div>
|
||||
|
||||
<blockquote class="note-window-quote hidden"></blockquote>
|
||||
|
||||
<div class="note-window-meta">
|
||||
<label>
|
||||
<span>笔记本</span>
|
||||
<select class="note-window-collection">
|
||||
<option value="">未分类</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="note-window-tags">
|
||||
<span>标签</span>
|
||||
<input class="note-window-tags-input" type="text" placeholder="用逗号分隔" />
|
||||
</label>
|
||||
<label class="note-window-pin">
|
||||
<input class="note-window-pinned" type="checkbox" /> 置顶
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="note-window-actions">
|
||||
<span class="note-window-status" role="status"></span>
|
||||
<button class="tb-btn note-window-save" type="submit">保存笔记</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<div id="noteDirtyModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="noteDirtyTitle">
|
||||
<div class="modal-box note-dirty-box">
|
||||
<div id="noteDirtyTitle" class="modal-title">这条笔记还没保存?</div>
|
||||
<p id="noteDirtyNotice" class="note-dirty-notice">关闭后未保存的修改会丢失。</p>
|
||||
<div class="modal-actions">
|
||||
<button id="noteDirtyCancelBtn" class="tb-btn ghost" type="button">取消</button>
|
||||
<button id="noteDirtyDiscardBtn" class="tb-btn danger" type="button">放弃修改</button>
|
||||
<button id="noteDirtySaveBtn" class="tb-btn" type="button">保存并关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/jszip.min.js"></script>
|
||||
<script src="vendor/quill/quill.js"></script>
|
||||
<script src="vendor/jspdf.umd.min.js"></script>
|
||||
<script src="vendor/purify.min.js"></script>
|
||||
<script src="rich-note.js"></script>
|
||||
<script src="mixed-note.js"></script>
|
||||
<script type="module" src="views/note-shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -468,6 +468,15 @@ input[type="color"],
|
||||
padding: 6px 8px; word-break: break-word; flex-shrink: 0;
|
||||
}
|
||||
.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
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function xmlAttr(value) {
|
||||
return xmlText(value).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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('无法打开', '这条笔记缺少书籍定位信息。');
|
||||
|
||||
Reference in New Issue
Block a user