笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
159 lines
5.7 KiB
JavaScript
159 lines
5.7 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const { discover } = require('../library/local-import');
|
|
|
|
const created = [];
|
|
|
|
function freshRoot(tag) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-local-import-${tag}-`));
|
|
created.push(root);
|
|
return root;
|
|
}
|
|
|
|
function write(root, relativePath, contents = '') {
|
|
const target = path.join(root, relativePath);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, contents);
|
|
return target;
|
|
}
|
|
|
|
test.after(() => {
|
|
for (const root of created) {
|
|
try { fs.rmSync(root, { recursive: true, force: true }); } catch (error) { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
test('discovers a directly selected supported file with a canonical record', async () => {
|
|
const root = freshRoot('direct');
|
|
const selected = write(root, 'A Book.PDF');
|
|
const canonical = fs.realpathSync(selected);
|
|
|
|
assert.deepStrictEqual(await discover([selected]), [{
|
|
path: canonical,
|
|
name: 'A Book.PDF',
|
|
format: 'pdf',
|
|
parentName: path.basename(root)
|
|
}]);
|
|
assert.ok(path.isAbsolute(canonical));
|
|
});
|
|
|
|
test('recursively discovers supported files and uses each immediate parent name', async () => {
|
|
const root = freshRoot('recursive');
|
|
const first = write(root, 'root.epub');
|
|
const second = write(root, path.join('Shelf One', 'nested.MOBI'));
|
|
const third = write(root, path.join('Shelf One', 'Deeper', 'last.fb2'));
|
|
|
|
const result = await discover([root]);
|
|
const byName = new Map(result.map((record) => [record.name, record]));
|
|
|
|
assert.deepStrictEqual(
|
|
new Set(result.map((record) => record.path)),
|
|
new Set([first, second, third].map((value) => fs.realpathSync(value)))
|
|
);
|
|
assert.strictEqual(byName.get('root.epub').parentName, path.basename(root));
|
|
assert.strictEqual(byName.get('nested.MOBI').parentName, 'Shelf One');
|
|
assert.strictEqual(byName.get('last.fb2').parentName, 'Deeper');
|
|
assert.deepStrictEqual(
|
|
Object.fromEntries(result.map((record) => [record.name, record.format])),
|
|
{ 'last.fb2': 'fb2', 'nested.MOBI': 'mobi', 'root.epub': 'epub' }
|
|
);
|
|
});
|
|
|
|
test('handles mixed file and directory inputs while skipping unsupported and non-files', async () => {
|
|
const root = freshRoot('mixed');
|
|
const folder = path.join(root, 'folder');
|
|
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.rtf');
|
|
fs.mkdirSync(path.join(root, 'empty'));
|
|
|
|
const result = await discover([
|
|
path.join(root, 'missing.pdf'),
|
|
path.join(root, 'README.rtf'),
|
|
path.join(root, 'empty'),
|
|
direct,
|
|
folder
|
|
]);
|
|
|
|
assert.deepStrictEqual(
|
|
result.map((record) => record.path),
|
|
[inFolder, azw, markdown, direct].map((value) => fs.realpathSync(value)).sort(),
|
|
'md 与 txt 都能进内置阅读器,必须和其他图书格式一样被本地导入发现'
|
|
);
|
|
});
|
|
|
|
test('de-duplicates repeated selections', async () => {
|
|
const root = freshRoot('duplicate');
|
|
const selected = write(root, 'duplicate.djvu');
|
|
|
|
const result = await discover([selected, root, selected]);
|
|
assert.strictEqual(result.length, 1);
|
|
assert.strictEqual(result[0].path, fs.realpathSync(selected));
|
|
});
|
|
|
|
test('does not follow symbolic links to files or directories when links are available', async (t) => {
|
|
const root = freshRoot('symlink');
|
|
const outside = freshRoot('symlink-target');
|
|
const ordinary = write(root, 'ordinary.cbr');
|
|
const linkedFileTarget = write(outside, 'linked.pdf');
|
|
const linkedDirectoryTarget = path.join(outside, 'books');
|
|
const nestedTarget = write(outside, path.join('books', 'nested.azw3'));
|
|
const fileLink = path.join(root, 'file-link.pdf');
|
|
const directoryLink = path.join(root, 'directory-link');
|
|
|
|
try {
|
|
fs.symlinkSync(linkedFileTarget, fileLink, 'file');
|
|
fs.symlinkSync(
|
|
linkedDirectoryTarget,
|
|
directoryLink,
|
|
process.platform === 'win32' ? 'junction' : 'dir'
|
|
);
|
|
} catch (error) {
|
|
t.skip(`symbolic links are unavailable: ${error.code || error.message}`);
|
|
return;
|
|
}
|
|
|
|
const result = await discover([root, fileLink, directoryLink]);
|
|
assert.deepStrictEqual(result.map((record) => record.path), [fs.realpathSync(ordinary)]);
|
|
assert.ok(!result.some((record) => record.path === fs.realpathSync(nestedTarget)));
|
|
});
|
|
|
|
test('returns a deterministic path-sorted order independent of selection order', async () => {
|
|
const root = freshRoot('order');
|
|
write(root, 'zeta.txt');
|
|
write(root, 'Alpha.pdf');
|
|
write(root, path.join('middle', 'beta.epub'));
|
|
|
|
const forward = await discover([path.join(root, 'zeta.txt'), path.join(root, 'middle'), root]);
|
|
const reverse = await discover([root, path.join(root, 'middle'), path.join(root, 'zeta.txt')]);
|
|
|
|
assert.deepStrictEqual(forward, reverse);
|
|
assert.deepStrictEqual(
|
|
forward.map((record) => record.path),
|
|
forward.map((record) => record.path).slice().sort((left, right) => {
|
|
const leftKey = process.platform === 'win32' ? left.toLowerCase() : left;
|
|
const rightKey = process.platform === 'win32' ? right.toLowerCase() : right;
|
|
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left < right ? -1 : left > right ? 1 : 0;
|
|
})
|
|
);
|
|
});
|
|
|
|
test('throws a clear Chinese error when the supported-file maximum is exceeded', async () => {
|
|
const root = freshRoot('maximum');
|
|
write(root, 'one.pdf');
|
|
write(root, 'two.epub');
|
|
write(root, 'three.mobi');
|
|
|
|
await assert.rejects(
|
|
discover([root], { maxFiles: 2 }),
|
|
/本地导入文件数量超过上限(最多 2 个)/
|
|
);
|
|
});
|