feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0

新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+707
View File
@@ -0,0 +1,707 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const h = require('./helpers');
h.installFetchStub();
h.setHandler(() => h.makeResponse({ status: 404 }));
const store = require('../library/store');
function tmpDir(tag) {
const d = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-${tag}-`));
return d;
}
const created = [];
function freshRoot(tag) {
const d = tmpDir(tag);
created.push(d);
store.init(d);
return d;
}
test.after(() => {
for (const d of created) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('init 建出目录结构', () => {
const root = freshRoot('init');
assert.ok(fs.existsSync(path.join(root, 'files')));
assert.ok(fs.existsSync(path.join(root, 'covers')));
assert.strictEqual(store.getRoot(), path.resolve(root));
});
test('add / get / list 往返', () => {
freshRoot('crud');
const it = store.add({ title: '测试书', authors: ['作者'], sourceId: 's', sourcePostId: 1 });
assert.ok(it.id);
const got = store.get(it.id);
assert.strictEqual(got.title, '测试书');
assert.strictEqual(got.sourcePostId, '1', 'sourcePostId 应统一为字符串');
assert.strictEqual(store.list().length, 1);
assert.ok(store.findBySource('s', 1), '数字 postId 应能匹配');
assert.ok(store.findBySource('s', '1'));
});
test('批量导入本地文件可按上一级目录创建并复用书架', () => {
const root = freshRoot('local-import-shelves');
const source = path.join(root, 'source');
for (const folder of ['文学', '技术']) fs.mkdirSync(path.join(source, folder), { recursive: true });
const files = [
path.join(source, '文学', '小说.epub'),
path.join(source, '文学', '诗集.pdf'),
path.join(source, '技术', '手册.txt')
];
files.forEach((file, index) => fs.writeFileSync(file, `fixture-${index}`));
const existingLiterature = store.addShelf('文学');
const records = files.map((file) => ({
path: file,
name: path.basename(file),
format: path.extname(file).slice(1).toUpperCase(),
parentName: path.basename(path.dirname(file))
}));
const imported = store.importLocal(records, 'shelf');
assert.strictEqual(imported.added, 3);
assert.strictEqual(imported.skipped, 0);
assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name).sort(), ['技术', '文学']);
const literature = store.listShelves().find((shelf) => shelf.name === '文学');
assert.strictEqual(literature.id, existingLiterature.id);
assert.strictEqual(
store.list().filter((item) => item.shelfId === literature.id).length,
2
);
const repeated = store.importLocal(records, 'shelf');
assert.deepStrictEqual(
{
added: repeated.added,
skipped: repeated.skipped,
skippedDuplicates: repeated.skippedDuplicates
},
{ added: 0, skipped: 3, skippedDuplicates: 3 }
);
assert.strictEqual(store.list().length, 3);
});
test('本地导入按规范路径跳过书库中已有的同一文件', () => {
const root = freshRoot('local-import-same-path');
const source = path.join(root, 'source', 'same.pdf');
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.writeFileSync(source, 'same-path-content');
const record = { path: source, name: 'same.pdf', parentName: 'source' };
assert.strictEqual(store.importLocal([record]).added, 1);
const repeated = store.importLocal([record]);
assert.deepStrictEqual(
{
added: repeated.added,
skipped: repeated.skipped,
skippedDuplicates: repeated.skippedDuplicates
},
{ added: 0, skipped: 1, skippedDuplicates: 1 }
);
assert.strictEqual(store.list().length, 1);
});
test('本地导入按文件字节跳过不同路径下的副本', () => {
const root = freshRoot('local-import-copy');
const original = path.join(root, 'original', 'first.pdf');
const copy = path.join(root, 'copy', 'renamed.pdf');
fs.mkdirSync(path.dirname(original), { recursive: true });
fs.mkdirSync(path.dirname(copy), { recursive: true });
fs.writeFileSync(original, 'identical-file-bytes');
fs.copyFileSync(original, copy);
assert.strictEqual(store.importLocal([{ path: original }]).added, 1);
const copied = store.importLocal([{
path: copy,
name: 'Completely Different Title.pdf',
title: 'Completely Different Title'
}]);
assert.deepStrictEqual(
{
added: copied.added,
skipped: copied.skipped,
skippedDuplicates: copied.skippedDuplicates
},
{ added: 0, skipped: 1, skippedDuplicates: 1 }
);
assert.strictEqual(store.list().length, 1);
});
test('本地导入保留同名但字节不同的版本', () => {
const root = freshRoot('local-import-editions');
const first = path.join(root, 'edition-one', 'Shared Title.pdf');
const second = path.join(root, 'edition-two', 'Shared Title.pdf');
fs.mkdirSync(path.dirname(first), { recursive: true });
fs.mkdirSync(path.dirname(second), { recursive: true });
fs.writeFileSync(first, 'edition-A');
fs.writeFileSync(second, 'edition-B');
const imported = store.importLocal([
{ path: first, title: 'Shared Title' },
{ path: second, title: 'Shared Title' }
]);
assert.deepStrictEqual(
{
added: imported.added,
skipped: imported.skipped,
skippedDuplicates: imported.skippedDuplicates
},
{ added: 2, skipped: 0, skippedDuplicates: 0 }
);
assert.deepStrictEqual(store.list().map((item) => item.title), ['Shared Title', 'Shared Title']);
});
test('混合批量导入同时跳过已有路径、已有副本和批内副本', () => {
const root = freshRoot('local-import-mixed');
const existing = path.join(root, 'existing', 'book.epub');
const existingCopy = path.join(root, 'incoming', 'existing-copy.epub');
const fresh = path.join(root, 'incoming', 'fresh.epub');
const freshCopy = path.join(root, 'incoming-copy', 'fresh-copy.epub');
for (const file of [existing, existingCopy, fresh, freshCopy]) {
fs.mkdirSync(path.dirname(file), { recursive: true });
}
fs.writeFileSync(existing, 'existing-content');
fs.copyFileSync(existing, existingCopy);
fs.writeFileSync(fresh, 'brand-new-content');
fs.copyFileSync(fresh, freshCopy);
store.importLocal([{ path: existing }]);
const imported = store.importLocal([
{ path: existing },
{ path: existingCopy },
{ path: fresh },
{ path: freshCopy }
]);
assert.deepStrictEqual(
{
added: imported.added,
skipped: imported.skipped,
skippedDuplicates: imported.skippedDuplicates
},
{ added: 1, skipped: 3, skippedDuplicates: 3 }
);
assert.strictEqual(imported.items[0].files[0].path, fs.realpathSync(fresh));
assert.strictEqual(store.list().length, 2);
});
test('本地批量导入写入失败时回滚条目、分类和去重状态', () => {
const root = freshRoot('local-import-rollback');
const source = path.join(root, '回滚分类');
const first = path.join(source, 'first.pdf');
const duplicate = path.join(source, 'first-copy.pdf');
const second = path.join(source, 'second.pdf');
fs.mkdirSync(source, { recursive: true });
fs.writeFileSync(first, 'duplicate-content');
fs.copyFileSync(first, duplicate);
fs.writeFileSync(second, 'distinct-content');
const records = [first, duplicate, second].map((file) => ({
path: file,
parentName: '回滚分类'
}));
const file = path.join(root, 'library.json');
const originalRename = fs.renameSync;
let failed = false;
fs.renameSync = function renameWithFailure(sourcePath, destination) {
if (!failed && sourcePath === `${file}.tmp` && destination === file) {
failed = true;
throw new Error('simulated replace failure');
}
return originalRename.apply(this, arguments);
};
try {
assert.throws(
() => store.importLocal(records, 'shelf'),
/书库索引写入失败/
);
} finally {
fs.renameSync = originalRename;
}
assert.ok(failed);
assert.deepStrictEqual(store.list(), []);
assert.deepStrictEqual(store.listShelves(), []);
assert.ok(!fs.existsSync(file));
assert.ok(!fs.existsSync(`${file}.tmp`));
const retried = store.importLocal(records, 'shelf');
assert.deepStrictEqual(
{
added: retried.added,
skipped: retried.skipped,
skippedDuplicates: retried.skippedDuplicates
},
{ added: 2, skipped: 1, skippedDuplicates: 1 }
);
assert.strictEqual(store.list().length, 2);
assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name), ['回滚分类']);
});
test('批量导入本地文件可按上一级目录创建标签或保持不分类', () => {
const root = freshRoot('local-import-tags');
const folder = path.join(root, '旧分类');
fs.mkdirSync(folder, { recursive: true });
const taggedFile = path.join(folder, '标签书.pdf');
const plainFile = path.join(folder, '普通书.epub');
fs.writeFileSync(taggedFile, 'tagged');
fs.writeFileSync(plainFile, 'plain');
const existingTag = store.addTag('旧分类');
const tagged = store.importLocal([{
path: taggedFile,
name: '标签书.pdf',
parentName: '旧分类'
}], 'tag');
assert.strictEqual(tagged.added, 1);
assert.deepStrictEqual(store.get(tagged.items[0].id).tags, ['旧分类']);
const taggedCatalog = store.listTags().find((tag) => tag.name === '旧分类');
assert.strictEqual(taggedCatalog.id, existingTag.id);
assert.strictEqual(taggedCatalog.count, 1);
const plain = store.importLocal([{
path: plainFile,
name: '普通书.epub',
parentName: '旧分类'
}], 'none');
assert.strictEqual(plain.added, 1);
assert.deepStrictEqual(store.get(plain.items[0].id).tags, []);
assert.strictEqual(store.get(plain.items[0].id).shelfId, null);
assert.throws(() => store.importLocal([], 'invalid'), /分类方式无效/);
});
test('书库内文件存相对路径,外部文件存绝对路径', () => {
const root = freshRoot('paths');
const inside = path.join(root, 'files', 'a.pdf');
fs.writeFileSync(inside, 'x');
const outsideDir = tmpDir('outside');
created.push(outsideDir);
const outside = path.join(outsideDir, 'b.pdf');
fs.writeFileSync(outside, 'y');
const it = store.add({ title: 'T', files: [{ path: inside }, { path: outside }] });
const raw = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
const stored = raw.items[0].files.map((f) => f.path);
assert.ok(stored.includes('files/a.pdf'), '库内文件未转相对路径: ' + stored);
assert.ok(stored.some((p) => path.isAbsolute(p)), '库外文件不应转相对路径');
// 对外一律给绝对路径
for (const f of it.files) assert.ok(path.isAbsolute(f.path), f.path);
assert.ok(it.files.every((f) => f.exists));
});
test('expand 如实反映磁盘状态', () => {
const root = freshRoot('missing');
const p = path.join(root, 'files', 'gone.pdf');
fs.writeFileSync(p, 'x');
const it = store.add({ title: 'T', files: [{ path: p }] });
assert.strictEqual(store.get(it.id).missing, false);
fs.unlinkSync(p);
const after = store.get(it.id);
assert.strictEqual(after.files[0].exists, false);
assert.strictEqual(after.missing, true);
});
test('allocFilePath 避免覆盖同名文件', () => {
const root = freshRoot('alloc');
const first = store.allocFilePath('book.pdf');
fs.writeFileSync(first, 'a');
const second = store.allocFilePath('book.pdf');
assert.notStrictEqual(first, second);
assert.ok(second.includes('(1)'), second);
});
test('sanitize 去掉非法字符', () => {
assert.strictEqual(store.sanitize('a/b:c*d?.pdf'), 'a_b_c_d_.pdf');
assert.strictEqual(store.sanitize(''), 'download');
assert.strictEqual(store.sanitize(' '), 'download');
});
test('remove 默认保留文件,deleteFiles 才删', () => {
const root = freshRoot('remove');
const p = path.join(root, 'files', 'keep.pdf');
fs.writeFileSync(p, 'x');
const a = store.add({ title: 'A', files: [{ path: p }] });
store.remove(a.id, false);
assert.ok(fs.existsSync(p), '未勾选删除时不该删文件');
const b = store.add({ title: 'B', files: [{ path: p }] });
store.remove(b.id, true);
assert.ok(!fs.existsSync(p), '勾选删除后文件应被删除');
});
test('remove 不删书库目录外的用户文件', () => {
freshRoot('remove-outside');
const outDir = tmpDir('user');
created.push(outDir);
const p = path.join(outDir, 'mine.pdf');
fs.writeFileSync(p, 'x');
const it = store.add({ title: 'T', files: [{ path: p }] });
store.remove(it.id, true);
assert.ok(fs.existsSync(p), '原地引用的外部文件被误删了');
});
test('scan 导入孤立文件并跳过非书籍扩展名', () => {
const root = freshRoot('scan');
fs.writeFileSync(path.join(root, 'files', 'novel.epub'), 'x');
fs.writeFileSync(path.join(root, 'files', 'notes.exe'), 'x');
const r = store.scan();
assert.strictEqual(r.added, 1, '应只导入 epub');
assert.strictEqual(store.list()[0].title, 'novel');
const again = store.scan();
assert.strictEqual(again.added, 0, '重复扫描不应重复导入');
});
test('attachFile 幂等,不产生重复条目文件', () => {
const root = freshRoot('attach');
const it = store.add({ title: 'T' });
const p = path.join(root, 'files', 'x.pdf');
fs.writeFileSync(p, 'x');
store.attachFile(it.id, p);
const after = store.attachFile(it.id, p);
assert.strictEqual(after.files.length, 1, '重复挂载产生了重复记录');
});
test('生成封面写入 covers 并随条目删除', () => {
const root = freshRoot('generated-cover');
const it = store.add({ title: 'T' });
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
const cover = store.setGeneratedCover(it.id, jpeg);
assert.ok(cover.startsWith(path.join(root, 'covers')), cover);
assert.ok(fs.existsSync(cover));
assert.strictEqual(store.get(it.id).cover, cover);
store.remove(it.id, false);
assert.ok(!fs.existsSync(cover), '移除条目后遗留了生成封面');
});
test('生成封面不覆盖更新后的来源封面', () => {
const root = freshRoot('generated-priority');
const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
store.update(it.id, { cover: 'https://new.example/cover.jpg' });
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
assert.strictEqual(store.setGeneratedCover(it.id, jpeg, it.cover), '');
assert.strictEqual(store.get(it.id).cover, 'https://new.example/cover.jpg');
const blank = store.add({ title: 'Blank' });
const manual = path.join(root, 'manual.jpg');
fs.writeFileSync(manual, jpeg);
store.update(blank.id, { cover: manual });
assert.strictEqual(store.setGeneratedCover(blank.id, jpeg, ''), '');
assert.strictEqual(store.get(blank.id).cover, manual);
});
test('生成封面拒绝非 JPEG 和过大数据', () => {
freshRoot('generated-validation');
const it = store.add({ title: 'T' });
assert.throws(() => store.setGeneratedCover(it.id, Buffer.from('not an image')), /JPEG/);
const large = Buffer.alloc(2 * 1024 * 1024 + 1);
large[0] = 0xff; large[1] = 0xd8; large[2] = 0xff;
assert.throws(() => store.setGeneratedCover(it.id, large), /JPEG/);
});
test('远程封面下载完成后不覆盖期间更新的封面', async () => {
const root = freshRoot('remote-cover-race');
let release;
let startedResolve;
const started = new Promise((resolve) => { startedResolve = resolve; });
h.setHandler(() => {
startedResolve();
return new Promise((resolve) => {
release = () => resolve({
ok: true,
arrayBuffer: async () => Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]).buffer
});
});
});
const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
const job = store.ensureCoverCached(it.id);
await started;
const manual = path.join(root, 'manual.jpg');
fs.writeFileSync(manual, Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
store.update(it.id, { cover: manual });
release();
assert.strictEqual(await job, '');
assert.strictEqual(store.get(it.id).cover, manual);
h.setHandler(() => h.makeResponse({ status: 404 }));
});
test('远程封面缓存拒绝网页响应', async () => {
const root = freshRoot('remote-cover-html');
h.setHandler(() => ({
ok: true,
arrayBuffer: async () => Uint8Array.from(Buffer.from('<html>not an image</html>')).buffer
}));
const it = store.add({ title: 'T', cover: 'https://example.com/cover.jpg' });
assert.strictEqual(await store.ensureCoverCached(it.id), '');
assert.strictEqual(store.get(it.id).cover, 'https://example.com/cover.jpg');
assert.deepStrictEqual(fs.readdirSync(path.join(root, 'covers')), []);
h.setHandler(() => h.makeResponse({ status: 404 }));
});
test('索引损坏时报错而不是静默清空书库', () => {
const root = freshRoot('corrupt');
store.add({ title: '重要的书' });
fs.writeFileSync(path.join(root, 'library.json'), '{ 坏掉的 json');
store.init(root);
assert.throws(() => store.list(), /书库索引读取失败/);
});
test('写入后可从 .bak 恢复', () => {
const root = freshRoot('bak');
store.add({ title: '书' });
const idx = path.join(root, 'library.json');
fs.copyFileSync(idx, idx + '.bak');
fs.unlinkSync(idx);
store.init(root);
assert.strictEqual(store.list().length, 1, '未从 .bak 恢复');
});
test('migrateTo 搬运文件并保持条目可用', () => {
const src = freshRoot('mig-src');
const shelf = store.addShelf({ name: '迁移书架' });
const p = path.join(src, 'files', 'm.pdf');
fs.writeFileSync(p, 'data');
const added = store.add({ title: 'M', shelfId: shelf.id, tags: ['迁移'], files: [{ path: p }] });
const oldCover = store.setGeneratedCover(
added.id,
Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9])
);
const dest = tmpDir('mig-dest');
created.push(dest);
store.migrateTo(dest);
store.finalizeMigration();
assert.strictEqual(store.getRoot(), path.resolve(dest));
const items = store.list();
assert.strictEqual(items.length, 1);
assert.ok(items[0].files[0].exists, '迁移后文件丢失');
assert.ok(items[0].files[0].path.startsWith(path.resolve(dest)), items[0].files[0].path);
assert.ok(items[0].cover.startsWith(path.resolve(dest)), items[0].cover);
assert.ok(fs.existsSync(items[0].cover), '迁移后生成封面丢失');
assert.strictEqual(items[0].shelfId, shelf.id);
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['迁移书架']);
assert.ok(!fs.existsSync(p), '旧文件未清理');
assert.ok(!fs.existsSync(oldCover), '旧生成封面未清理');
});
test('migrateTo 拒绝互相包含的目录', () => {
const src = freshRoot('mig-nest');
assert.throws(() => store.migrateTo(path.join(src, 'sub')), /不能互相包含/);
});
test('migrateTo 拒绝已有书库的目标目录', () => {
freshRoot('mig-occupied');
store.add({ title: 'A' });
const dest = tmpDir('mig-taken');
created.push(dest);
fs.writeFileSync(path.join(dest, 'library.json'), '{}');
assert.throws(() => store.migrateTo(dest), /已包含书库索引/);
});
test('rollbackMigration 回到原目录且不留残File', () => {
const src = freshRoot('mig-rb');
const shelf = store.addShelf('回滚书架');
const p = path.join(src, 'files', 'r.pdf');
fs.writeFileSync(p, 'data');
store.add({ title: 'R', shelfId: shelf.id, files: [{ path: p }] });
const dest = tmpDir('mig-rb-dest');
created.push(dest);
store.migrateTo(dest);
store.rollbackMigration();
assert.strictEqual(store.getRoot(), path.resolve(src));
assert.ok(fs.existsSync(p), '回滚后源文件应还在');
assert.ok(!fs.existsSync(path.join(dest, 'library.json')), '目标目录索引未清理');
assert.strictEqual(store.list().length, 1);
assert.strictEqual(store.list()[0].shelfId, shelf.id);
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['回滚书架']);
});
test('importLegacy 正确复制相对路径封面', () => {
const legacy = tmpDir('legacy-relative-cover');
created.push(legacy);
fs.mkdirSync(path.join(legacy, 'covers'), { recursive: true });
fs.writeFileSync(path.join(legacy, 'covers', 'old.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify([{
id: 'legacy-book',
title: 'Legacy',
cover: 'covers/old.jpg',
files: []
}]));
const root = freshRoot('legacy-relative-dest');
assert.strictEqual(store.importLegacy(legacy).imported, 1);
const imported = store.get('legacy-book');
assert.ok(imported.cover.startsWith(path.join(root, 'covers')), imported.cover);
assert.ok(fs.existsSync(imported.cover));
});
test('update 修改字段并刷新 updatedAt', () => {
freshRoot('update');
const it = store.add({ title: '旧' });
const out = store.update(it.id, { title: '新', tags: ['t'] });
assert.strictEqual(out.title, '新');
assert.deepStrictEqual(out.tags, ['t']);
assert.throws(() => store.update('nope', {}), /条目不存在/);
});
test('v1 和 v2 索引透明迁移到 v4 并保留条目与标签目录', () => {
for (const fixture of [
{
tag: 'schema-v1',
data: [{ id: 'v1', title: '旧数组', custom: { kept: true }, tags: [' A ', 'a', ''] }]
},
{
tag: 'schema-v2',
data: {
version: 2,
items: [{ id: 'v2', title: '旧对象', custom: { kept: true }, tags: ['B'], shelfId: 'missing' }]
}
}
]) {
const root = freshRoot(fixture.tag);
fs.writeFileSync(path.join(root, 'library.json'), JSON.stringify(fixture.data));
store.init(root);
const item = store.list()[0];
assert.deepStrictEqual(item.custom, { kept: true });
assert.strictEqual(item.shelfId, null);
assert.strictEqual(item.tags.length, 1);
store.update(item.id, { title: item.title });
const persisted = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
assert.strictEqual(persisted.version, 4);
assert.deepStrictEqual(persisted.shelves, []);
assert.strictEqual(persisted.tags.length, 1);
assert.strictEqual(persisted.tags[0].name, item.tags[0]);
assert.deepStrictEqual(persisted.items[0].custom, { kept: true });
}
});
test('书架 CRUD 强制唯一非空名称并返回深拷贝', () => {
freshRoot('shelf-crud');
const shelf = store.addShelf({ name: ' 技术 ' });
assert.match(shelf.id, /^shelf_[a-f0-9]{24}$/);
assert.strictEqual(shelf.name, '技术');
assert.ok(Number.isFinite(shelf.createdAt));
assert.ok(Number.isFinite(shelf.updatedAt));
assert.throws(() => store.addShelf(' '), /不能为空/);
assert.throws(() => store.addShelf('技术'), /已存在/);
const listed = store.listShelves();
listed[0].name = '被外部修改';
listed.push({ id: 'fake', name: '假的' });
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['技术']);
const updated = store.updateShelf(shelf.id, { name: ' 文学 ' });
assert.strictEqual(updated.name, '文学');
assert.strictEqual(updated.createdAt, shelf.createdAt);
assert.ok(updated.updatedAt >= shelf.updatedAt);
updated.name = '再次外部修改';
assert.strictEqual(store.listShelves()[0].name, '文学');
assert.throws(() => store.updateShelf('missing', { name: 'X' }), /不存在/);
const other = store.addShelf('Research');
assert.throws(() => store.addShelf(' research '), /已存在/);
assert.throws(() => store.updateShelf(other.id, { name: ' 文学 ' }), /已存在/);
});
test('删除书架只清空条目 shelfId 且组织变更触发通知', () => {
freshRoot('shelf-remove');
let changes = 0;
store.setChangeListener(() => { changes++; });
try {
const shelf = store.addShelf('待整理');
const book = store.add({ title: '保留我' });
store.update(book.id, { shelfId: shelf.id, tags: [' A ', 'a', 'B'] });
assert.strictEqual(changes, 2, '书架添加和组织更新均应通知');
assert.strictEqual(store.get(book.id).shelfId, shelf.id);
assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: true });
assert.strictEqual(changes, 3);
assert.strictEqual(store.list().length, 1, '删除书架不应删除书籍');
assert.strictEqual(store.get(book.id).shelfId, null);
assert.deepStrictEqual(store.get(book.id).tags, ['A', 'B']);
assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: false });
assert.strictEqual(changes, 3, '重复删除不存在的书架不应通知');
} finally {
store.setChangeListener(null);
}
});
test('条目组织字段归一化并限制标签数量和长度', () => {
freshRoot('organization-normalize');
const shelf = store.addShelf('有效书架');
const manyTags = Array.from({ length: 60 }, (_, i) => ` tag-${i} `);
const book = store.add({
title: '组织',
shelfId: shelf.id,
tags: [' Foo ', 'foo', null, '', 'x'.repeat(80), ...manyTags]
});
assert.strictEqual(book.shelfId, shelf.id);
assert.strictEqual(book.tags[0], 'Foo');
assert.strictEqual(book.tags[1].length, 64);
assert.strictEqual(book.tags.length, 50);
const invalid = store.update(book.id, { shelfId: 'not-a-shelf', tags: 'not-an-array' });
assert.strictEqual(invalid.shelfId, null);
assert.deepStrictEqual(invalid.tags, []);
});
test('listTags 合并大小写、保留稳定 ID 并按数量和中文名称排序', () => {
freshRoot('tag-catalog');
store.add({ title: '一', tags: [' 科学 ', 'SCIENCE', '历史'] });
store.add({ title: '二', tags: ['科学', 'science', '文学'] });
store.add({ title: '三', tags: ['Science'] });
const actual = store.listTags();
assert.ok(actual.every((tag) => /^tag_[a-f0-9]{24}$/.test(tag.id)));
assert.deepStrictEqual(actual.slice(0, 2).map(({ name, count }) => ({ name, count })), [
{ name: 'SCIENCE', count: 3 },
{ name: '科学', count: 2 }
]);
const tied = actual.slice(2).map(({ name, count }) => ({ name, count }));
assert.deepStrictEqual(
tied,
[{ name: '历史', count: 1 }, { name: '文学', count: 1 }]
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' }))
);
});
test('importLegacy 保留书架、标签并将同名书架映射到现有书架', () => {
const legacy = tmpDir('legacy-shelves');
created.push(legacy);
fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify({
version: 3,
shelves: [
{ id: 'old-shared', name: ' 已有 ', createdAt: 10, updatedAt: 20 },
{ id: 'old-new', name: '新书架', createdAt: 30, updatedAt: 40 }
],
items: [
{ id: 'legacy-shared-book', title: '共享', shelfId: 'old-shared', tags: [' A ', 'a'] },
{ id: 'legacy-new-book', title: '新增', shelfId: 'old-new', tags: [' B '] }
]
}));
freshRoot('legacy-shelves-dest');
const existing = store.addShelf('已有');
assert.strictEqual(store.importLegacy(legacy).imported, 2);
const importedShelves = store.listShelves();
assert.deepStrictEqual(importedShelves.map((entry) => entry.name), ['已有', '新书架']);
assert.strictEqual(importedShelves[1].createdAt, 30);
assert.strictEqual(store.get('legacy-shared-book').shelfId, existing.id);
assert.strictEqual(store.get('legacy-new-book').shelfId, importedShelves[1].id);
assert.deepStrictEqual(store.get('legacy-shared-book').tags, ['A']);
const raw = JSON.parse(fs.readFileSync(path.join(store.getRoot(), 'library.json'), 'utf8'));
assert.strictEqual(raw.version, 4);
assert.strictEqual(raw.shelves.length, 2);
assert.deepStrictEqual(raw.tags.map((tag) => tag.name).sort(), ['A', 'B']);
});