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
+147
View File
@@ -0,0 +1,147 @@
const fs = require('fs/promises');
const path = require('path');
const BOOK_EXT = new Set([
'pdf',
'epub',
'mobi',
'azw',
'azw3',
'txt',
'djvu',
'fb2',
'cbz',
'cbr'
]);
const DEFAULT_MAX_FILES = 10_000;
function pathKey(value) {
return process.platform === 'win32' ? value.toLowerCase() : value;
}
function comparePaths(left, right) {
const leftKey = pathKey(left);
const rightKey = pathKey(right);
if (leftKey < rightKey) return -1;
if (leftKey > rightKey) return 1;
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
async function safeLstat(value) {
try {
return await fs.lstat(value);
} catch (error) {
return null;
}
}
async function canonicalEntry(value, expectedType) {
const before = await safeLstat(value);
if (!before || before.isSymbolicLink() || !before[expectedType]()) return null;
let canonical;
try {
canonical = await fs.realpath(value);
} catch (error) {
return null;
}
// Recheck the directory entry after realpath so an entry changed to a link
// during discovery is not intentionally traversed or imported.
const after = await safeLstat(value);
if (!after || after.isSymbolicLink() || !after[expectedType]()) return null;
return path.resolve(canonical);
}
function maximumFrom(options) {
if (options && Object.prototype.hasOwnProperty.call(options, 'maxFiles')) {
const maximum = options.maxFiles;
if (!Number.isSafeInteger(maximum) || maximum < 1) {
throw new TypeError('本地导入文件数量上限必须是正整数');
}
return maximum;
}
return DEFAULT_MAX_FILES;
}
async function discover(paths, options = {}) {
const maximum = maximumFrom(options);
const selected = Array.isArray(paths) ? paths : [paths];
const candidates = selected
.filter((value) => typeof value === 'string' && value.length > 0)
.map((value) => path.resolve(value))
.sort(comparePaths);
const records = [];
const seenFiles = new Set();
const visitedDirectories = new Set();
function addFile(canonical) {
const key = pathKey(canonical);
if (seenFiles.has(key)) return;
if (records.length >= maximum) {
throw new Error(`本地导入文件数量超过上限(最多 ${maximum} 个)`);
}
seenFiles.add(key);
const name = path.basename(canonical);
records.push({
path: canonical,
name,
format: path.extname(name).slice(1).toLowerCase(),
parentName: path.basename(path.dirname(canonical))
});
}
async function visitFile(value) {
const extension = path.extname(value).slice(1).toLowerCase();
if (!BOOK_EXT.has(extension)) return;
const canonical = await canonicalEntry(value, 'isFile');
if (canonical) addFile(canonical);
}
async function visitDirectory(value) {
const canonical = await canonicalEntry(value, 'isDirectory');
if (!canonical) return;
const key = pathKey(canonical);
if (visitedDirectories.has(key)) return;
visitedDirectories.add(key);
let entries;
try {
entries = await fs.readdir(canonical, { withFileTypes: true });
} catch (error) {
return;
}
entries.sort((left, right) => comparePaths(left.name, right.name));
for (const entry of entries) {
const child = path.join(canonical, entry.name);
const stat = await safeLstat(child);
if (!stat || stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
await visitDirectory(child);
} else if (stat.isFile()) {
await visitFile(child);
}
}
}
for (const candidate of candidates) {
const stat = await safeLstat(candidate);
if (!stat || stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
await visitDirectory(candidate);
} else if (stat.isFile()) {
await visitFile(candidate);
}
}
records.sort((left, right) => comparePaths(left.path, right.path));
return records;
}
module.exports = { discover };