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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
@@ -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']);
|
||||
});
|
||||
Reference in New Issue
Block a user