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,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 };
|
||||
Reference in New Issue
Block a user