feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读
笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
This commit is contained in:
+87
-35
@@ -13,6 +13,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const atomic = require('../atomic-file');
|
||||
const { fetchWithProxy } = require('../sources/http');
|
||||
|
||||
const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
||||
@@ -20,7 +21,7 @@ const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHT
|
||||
const SCHEMA_VERSION = 4;
|
||||
const MAX_TAGS = 50;
|
||||
const MAX_TAG_LENGTH = 64;
|
||||
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr']);
|
||||
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr']);
|
||||
|
||||
let rootDir = null;
|
||||
let items = null;
|
||||
@@ -129,36 +130,14 @@ function load() {
|
||||
}
|
||||
|
||||
function persistTo(dir, value, shelfValue = shelves || [], tagValue = tags || []) {
|
||||
const dest = path.join(dir, 'library.json');
|
||||
const temp = `${dest}.tmp`;
|
||||
const backup = `${dest}.bak`;
|
||||
let backedUp = false;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
temp,
|
||||
JSON.stringify({
|
||||
version: SCHEMA_VERSION,
|
||||
shelves: shelfValue,
|
||||
tags: tagValue,
|
||||
items: value
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.renameSync(dest, backup);
|
||||
backedUp = true;
|
||||
}
|
||||
fs.renameSync(temp, dest);
|
||||
if (backedUp) {
|
||||
try { fs.unlinkSync(backup); } catch (cleanupError) { /* 保留备份不影响提交 */ }
|
||||
}
|
||||
atomic.writeJson(path.join(dir, 'library.json'), {
|
||||
version: SCHEMA_VERSION,
|
||||
shelves: shelfValue,
|
||||
tags: tagValue,
|
||||
items: value
|
||||
});
|
||||
} catch (e) {
|
||||
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
|
||||
try {
|
||||
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
||||
} catch (rollbackError) { /* 下次加载时会从 .bak 恢复 */ }
|
||||
throw new Error(`书库索引写入失败: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
@@ -788,11 +767,7 @@ function update(id, patch) {
|
||||
load();
|
||||
const it = items.find((x) => x.id === id);
|
||||
if (!it) throw new Error('条目不存在');
|
||||
const next = { ...patch };
|
||||
if (next.files) next.files = next.files.map(normalizeFile);
|
||||
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) next.shelfId = normalizeShelfId(next.shelfId);
|
||||
const next = normalizedPatch(patch);
|
||||
const updated = { ...it, ...next, updatedAt: Date.now() };
|
||||
const nextItems = items.map((x) => x.id === id ? updated : x);
|
||||
const organizationChanged = Object.prototype.hasOwnProperty.call(next, 'tags')
|
||||
@@ -929,6 +904,82 @@ function attachFile(id, filePath) {
|
||||
return expand(updated);
|
||||
}
|
||||
|
||||
function normalizedPatch(patch) {
|
||||
const next = { ...patch };
|
||||
if (next.files) next.files = next.files.map(normalizeFile);
|
||||
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) {
|
||||
next.shelfId = normalizeShelfId(next.shelfId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// 批量整理走单次 commit:逐条 update 会把整个索引重写 N 遍,
|
||||
// 几千条的书库里批量改动会卡住界面
|
||||
function updateMany(patches) {
|
||||
load();
|
||||
if (!Array.isArray(patches) || !patches.length) return { updated: 0 };
|
||||
const byId = new Map();
|
||||
for (const entry of patches) {
|
||||
if (!entry || entry.id == null) throw new Error('批量更新缺少条目 ID');
|
||||
const id = String(entry.id);
|
||||
if (!items.some((x) => x.id === id)) throw new Error('条目不存在');
|
||||
byId.set(id, normalizedPatch(entry.patch || {}));
|
||||
}
|
||||
const now = Date.now();
|
||||
let updated = 0;
|
||||
const nextItems = items.map((x) => {
|
||||
const patch = byId.get(x.id);
|
||||
if (!patch) return x;
|
||||
updated++;
|
||||
return { ...x, ...patch, updatedAt: now };
|
||||
});
|
||||
commit(nextItems, true);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
function removeMany(ids, deleteFiles) {
|
||||
load();
|
||||
if (!Array.isArray(ids) || !ids.length) return { removed: 0 };
|
||||
const targets = ids.map((id) => String(id));
|
||||
const unique = new Set(targets);
|
||||
const doomed = items.filter((x) => unique.has(x.id));
|
||||
const nextItems = items.filter((x) => !unique.has(x.id));
|
||||
const staged = [];
|
||||
if (deleteFiles) {
|
||||
try {
|
||||
for (const it of doomed) {
|
||||
for (const f of it.files || []) {
|
||||
const abs = toAbsolute(f.path);
|
||||
if (!abs || !isWithin(rootDir, abs) || !fs.existsSync(abs)) continue;
|
||||
const temp = `${abs}.deleting-${genId()}`;
|
||||
fs.renameSync(abs, temp);
|
||||
staged.push({ abs, temp });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
for (const f of staged.reverse()) {
|
||||
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
try {
|
||||
commit(nextItems, true);
|
||||
} catch (e) {
|
||||
for (const f of staged.reverse()) {
|
||||
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
for (const f of staged) {
|
||||
try { fs.unlinkSync(f.temp); } catch (e) { /* 文件已移出书库,稍后可手动清理 */ }
|
||||
}
|
||||
for (const it of doomed) removeCoverFile(it.id);
|
||||
return { removed: doomed.length };
|
||||
}
|
||||
|
||||
function remove(id, deleteFiles) {
|
||||
load();
|
||||
const it = items.find((x) => x.id === id);
|
||||
@@ -1262,7 +1313,8 @@ function importLegacy(legacyDir) {
|
||||
module.exports = {
|
||||
init, getRoot, filesDir, allocFilePath, sanitize,
|
||||
list, get, findBySource, listShelves, listTags,
|
||||
add, importLocal, update, remove, attachFile, addShelf, updateShelf, removeShelf,
|
||||
add, importLocal, update, updateMany, remove, removeMany, attachFile,
|
||||
addShelf, updateShelf, removeShelf,
|
||||
addTag, updateTag, removeTag,
|
||||
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
|
||||
ensureCoverCached, setGeneratedCover, setChangeListener
|
||||
|
||||
Reference in New Issue
Block a user