笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
149 lines
3.9 KiB
JavaScript
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 };
|