Files
peoplelib/src/library/local-import.js
T
lofyer 0fd7c59e08 feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读
笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

AI 助手支持多轮会话:会话独立落盘,先取历史再写提问,
历史只发文本不重发图像,失败与取消都保留已流出的残片。

新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线),
补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md,
但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。

书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底,
修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框
去掉衬底色块,恢复原生外观。

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
2026-08-04 16:19:06 +08:00

149 lines
3.9 KiB
JavaScript

const fs = require('fs/promises');
const path = require('path');
const BOOK_EXT = new Set([
'pdf',
'epub',
'mobi',
'azw',
'azw3',
'txt',
'md',
'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 };