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
+156
View File
@@ -0,0 +1,156 @@
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');
write(root, path.join('folder', 'cover.jpg'));
write(root, 'README.md');
fs.mkdirSync(path.join(root, 'empty'));
const result = await discover([
path.join(root, 'missing.pdf'),
path.join(root, 'README.md'),
path.join(root, 'empty'),
direct,
folder
]);
assert.deepStrictEqual(
result.map((record) => record.path),
[inFolder, azw, direct].map((value) => fs.realpathSync(value)).sort()
);
});
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 个)/
);
});