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

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

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

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

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

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
This commit is contained in:
lofyer
2026-08-04 16:19:06 +08:00
parent 522b0f74a5
commit 0fd7c59e08
68 changed files with 11721 additions and 301 deletions
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const atomic = require('../atomic-file');
const dirs = [];
function fresh(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-atomic-${tag}-`));
dirs.push(dir);
return dir;
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('元数据写入在改名前 fsync 数据', () => {
const dir = fresh('fsync');
const dest = path.join(dir, 'meta.json');
const order = [];
const realFsync = fs.fsyncSync;
const realRename = fs.renameSync;
fs.fsyncSync = function tracked(fd) {
order.push('fsync');
return realFsync.call(this, fd);
};
fs.renameSync = function tracked(from, to) {
order.push(`rename:${path.basename(String(from))}->${path.basename(String(to))}`);
return realRename.call(this, from, to);
};
try {
atomic.writeJson(dest, { hello: '世界' });
} finally {
fs.fsyncSync = realFsync;
fs.renameSync = realRename;
}
// rename 只保证目录项替换原子,不保证数据已落盘;
// 因此临时文件的 fsync 必须发生在改名之前
const firstRename = order.findIndex((step) => step.startsWith('rename:'));
assert.ok(firstRename > 0, `改名前应先 fsync,实际顺序 ${order.join(',')}`);
assert.strictEqual(order[0], 'fsync');
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { hello: '世界' });
});
test('写入失败时清理临时文件并把备份换回原位', () => {
const dir = fresh('rollback');
const dest = path.join(dir, 'meta.json');
atomic.writeJson(dest, { round: 1 });
const before = fs.readFileSync(dest, 'utf8');
const realRename = fs.renameSync;
let failed = false;
fs.renameSync = function failing(from, to) {
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
failed = true;
throw new Error('模拟替换失败');
}
return realRename.apply(this, arguments);
};
try {
assert.throws(() => atomic.writeJson(dest, { round: 2 }), /模拟替换失败/);
} finally {
fs.renameSync = realRename;
}
assert.ok(failed);
assert.strictEqual(fs.readFileSync(dest, 'utf8'), before);
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
});
test('目录 fsync 失败不影响写入结果', () => {
const dir = fresh('dirfail');
const dest = path.join(dir, 'meta.json');
const realOpen = fs.openSync;
// Windows 无法对目录取句柄,这条路径必须容错
fs.openSync = function guarded(target, flags, ...rest) {
if (String(target) === dir) throw new Error('EISDIR');
return realOpen.call(this, target, flags, ...rest);
};
try {
atomic.writeJson(dest, { ok: true });
} finally {
fs.openSync = realOpen;
}
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
});