笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
1322 lines
43 KiB
JavaScript
1322 lines
43 KiB
JavaScript
// 本地书库:元数据 + 文件统一存放在「书库目录」下,用户可在设置中更改。
|
||
//
|
||
// <书库目录>/
|
||
// library.json 元数据索引(含 schema 版本)
|
||
// files/ 下载的书籍文件
|
||
// covers/ 封面缓存
|
||
//
|
||
// 路径存储约定:
|
||
// - 书库目录内的文件记相对路径(files/xxx.pdf),随书库目录整体迁移仍然有效
|
||
// - 用户「添加本地文件」时原地引用,记绝对路径,不复制
|
||
// 对外一律返回拼好的绝对路径,渲染层不需要关心这个区别。
|
||
|
||
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';
|
||
|
||
const SCHEMA_VERSION = 4;
|
||
const MAX_TAGS = 50;
|
||
const MAX_TAG_LENGTH = 64;
|
||
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr']);
|
||
|
||
let rootDir = null;
|
||
let items = null;
|
||
let shelves = null;
|
||
let tags = null;
|
||
let changeListener = null;
|
||
let pendingMigration = null;
|
||
const coverCacheJobs = new Map();
|
||
|
||
function setChangeListener(fn) { changeListener = typeof fn === 'function' ? fn : null; }
|
||
function notifyChange() { if (changeListener) { try { changeListener(); } catch (e) { /* ignore */ } } }
|
||
|
||
// --- 目录 ---
|
||
|
||
function init(dir) {
|
||
const nextRoot = path.resolve(dir);
|
||
for (const d of [nextRoot, path.join(nextRoot, 'files'), path.join(nextRoot, 'covers')]) {
|
||
fs.mkdirSync(d, { recursive: true });
|
||
}
|
||
rootDir = nextRoot;
|
||
items = null;
|
||
shelves = null;
|
||
tags = null;
|
||
}
|
||
|
||
function getRoot() { return rootDir; }
|
||
function filesDir() { return path.join(rootDir, 'files'); }
|
||
function coversDir() { return path.join(rootDir, 'covers'); }
|
||
function indexFile() { return path.join(rootDir, 'library.json'); }
|
||
|
||
function ensureDirs() {
|
||
for (const d of [rootDir, filesDir(), coversDir()]) {
|
||
fs.mkdirSync(d, { recursive: true });
|
||
}
|
||
}
|
||
|
||
// --- 路径归一化 ---
|
||
|
||
function isWithin(base, target) {
|
||
const rel = path.relative(path.resolve(base), path.resolve(target));
|
||
return !!rel && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
||
}
|
||
|
||
function toRelative(abs) {
|
||
if (!abs) return '';
|
||
if (/^(?:https?:|data:)/i.test(abs)) return abs;
|
||
const rel = path.relative(rootDir, abs);
|
||
// 仍在书库目录内才转相对路径;外部文件保持绝对路径
|
||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel.split(path.sep).join('/');
|
||
return abs;
|
||
}
|
||
|
||
function toAbsolute(stored) {
|
||
if (!stored) return '';
|
||
if (/^(?:https?:|data:)/i.test(stored)) return stored;
|
||
return path.isAbsolute(stored) ? stored : path.join(rootDir, stored.split('/').join(path.sep));
|
||
}
|
||
|
||
// 对外输出:补上绝对路径与存在性,渲染层直接可用
|
||
function expand(it) {
|
||
if (!it) return it;
|
||
const files = (it.files || []).map((f) => {
|
||
const abs = toAbsolute(f.path);
|
||
return { ...f, path: abs, exists: !!abs && fs.existsSync(abs) };
|
||
});
|
||
return { ...it, cover: toAbsolute(it.cover), files, missing: files.length > 0 && files.every((f) => !f.exists) };
|
||
}
|
||
|
||
// --- 持久化 ---
|
||
|
||
function load() {
|
||
if (items) return items;
|
||
try {
|
||
const file = indexFile();
|
||
const backup = `${file}.bak`;
|
||
if (!fs.existsSync(file) && fs.existsSync(backup)) fs.renameSync(backup, file);
|
||
const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||
// v1 是裸数组;v2 是 { version, items };v3 增加 shelves;v4 增加 tags。
|
||
let rawItems;
|
||
let rawShelves;
|
||
let rawTags;
|
||
if (Array.isArray(raw)) {
|
||
rawItems = raw;
|
||
rawShelves = [];
|
||
rawTags = [];
|
||
} else if (raw && Array.isArray(raw.items)) {
|
||
rawItems = raw.items;
|
||
rawShelves = Array.isArray(raw.shelves) ? raw.shelves : [];
|
||
rawTags = Array.isArray(raw.tags) ? raw.tags : [];
|
||
} else {
|
||
throw new Error('索引格式无效');
|
||
}
|
||
const normalized = normalizeStoredShelves(rawShelves);
|
||
shelves = normalized.value;
|
||
items = rawItems.map((it) => normalizeItemOrganization(it, shelves, normalized.idMap));
|
||
tags = ensureCatalogTags(rawTags, items);
|
||
} catch (e) {
|
||
if (e && e.code === 'ENOENT') {
|
||
items = [];
|
||
shelves = [];
|
||
tags = [];
|
||
}
|
||
else throw new Error(`书库索引读取失败: ${e.message || e}`);
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function persistTo(dir, value, shelfValue = shelves || [], tagValue = tags || []) {
|
||
try {
|
||
atomic.writeJson(path.join(dir, 'library.json'), {
|
||
version: SCHEMA_VERSION,
|
||
shelves: shelfValue,
|
||
tags: tagValue,
|
||
items: value
|
||
});
|
||
} catch (e) {
|
||
throw new Error(`书库索引写入失败: ${e.message || e}`);
|
||
}
|
||
}
|
||
|
||
function commit(nextItems, notify = false, nextShelves = shelves || [], nextTags = null) {
|
||
const catalog = ensureCatalogTags(nextTags === null ? (tags || []) : nextTags, nextItems);
|
||
persistTo(rootDir, nextItems, nextShelves, catalog);
|
||
items = nextItems;
|
||
shelves = nextShelves;
|
||
tags = catalog;
|
||
if (notify) notifyChange();
|
||
}
|
||
|
||
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
||
|
||
function tagKey(name) { return name.toLowerCase(); }
|
||
|
||
function normalizeTags(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
const result = [];
|
||
const seen = new Set();
|
||
for (const raw of value) {
|
||
if (raw == null) continue;
|
||
let name = String(raw).trim();
|
||
if (!name) continue;
|
||
name = Array.from(name).slice(0, MAX_TAG_LENGTH).join('').trim();
|
||
if (!name) continue;
|
||
const key = tagKey(name);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
result.push(name);
|
||
if (result.length >= MAX_TAGS) break;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function genTagId(usedIds) {
|
||
let id;
|
||
do {
|
||
id = `tag_${crypto.randomBytes(12).toString('hex')}`;
|
||
} while (usedIds.has(id));
|
||
return id;
|
||
}
|
||
|
||
function normalizeStoredTags(value) {
|
||
const result = [];
|
||
const ids = new Set();
|
||
const names = new Set();
|
||
const now = Date.now();
|
||
for (const raw of Array.isArray(value) ? value : []) {
|
||
const rawName = typeof raw === 'string'
|
||
? raw
|
||
: (raw && typeof raw === 'object' ? raw.name : '');
|
||
const name = normalizeTags([rawName])[0];
|
||
if (!name) continue;
|
||
const nameKey = tagKey(name);
|
||
if (names.has(nameKey)) continue;
|
||
const originalId = raw && typeof raw === 'object'
|
||
&& typeof raw.id === 'string' && raw.id ? raw.id : '';
|
||
const id = originalId && !ids.has(originalId) ? originalId : genTagId(ids);
|
||
result.push({
|
||
id,
|
||
name,
|
||
createdAt: raw && typeof raw === 'object' && Number.isFinite(raw.createdAt)
|
||
? raw.createdAt
|
||
: now,
|
||
updatedAt: raw && typeof raw === 'object' && Number.isFinite(raw.updatedAt)
|
||
? raw.updatedAt
|
||
: now
|
||
});
|
||
ids.add(id);
|
||
names.add(nameKey);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function ensureCatalogTags(catalogValue, itemValue) {
|
||
const result = normalizeStoredTags(catalogValue);
|
||
const ids = new Set(result.map((tag) => tag.id));
|
||
const names = new Set(result.map((tag) => tagKey(tag.name)));
|
||
const now = Date.now();
|
||
for (const item of Array.isArray(itemValue) ? itemValue : []) {
|
||
for (const name of normalizeTags(item && item.tags)) {
|
||
const key = tagKey(name);
|
||
if (names.has(key)) continue;
|
||
result.push({
|
||
id: genTagId(ids),
|
||
name,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
});
|
||
ids.add(result[result.length - 1].id);
|
||
names.add(key);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function shelfNameKey(name) { return name.toLowerCase(); }
|
||
|
||
function genShelfId(usedIds) {
|
||
let id;
|
||
do {
|
||
id = `shelf_${crypto.randomBytes(12).toString('hex')}`;
|
||
} while (usedIds.has(id));
|
||
return id;
|
||
}
|
||
|
||
function normalizeStoredShelves(value) {
|
||
const result = [];
|
||
const idMap = new Map();
|
||
const ids = new Set();
|
||
const names = new Map();
|
||
const now = Date.now();
|
||
for (const raw of Array.isArray(value) ? value : []) {
|
||
if (!raw || typeof raw !== 'object') continue;
|
||
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
||
if (!name) continue;
|
||
const nameKey = shelfNameKey(name);
|
||
const originalId = typeof raw.id === 'string' && raw.id ? raw.id : '';
|
||
if (names.has(nameKey)) {
|
||
if (originalId) idMap.set(originalId, names.get(nameKey).id);
|
||
continue;
|
||
}
|
||
const id = originalId && !ids.has(originalId) ? originalId : genShelfId(ids);
|
||
const shelf = {
|
||
id,
|
||
name,
|
||
createdAt: Number.isFinite(raw.createdAt) ? raw.createdAt : now,
|
||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
|
||
};
|
||
result.push(shelf);
|
||
ids.add(id);
|
||
names.set(nameKey, shelf);
|
||
if (originalId && !idMap.has(originalId)) idMap.set(originalId, id);
|
||
}
|
||
return { value: result, idMap };
|
||
}
|
||
|
||
function normalizeShelfId(value, availableShelves = shelves || [], idMap = null) {
|
||
if (typeof value !== 'string' || !value) return null;
|
||
const mapped = idMap && idMap.has(value) ? idMap.get(value) : value;
|
||
return availableShelves.some((shelf) => shelf.id === mapped) ? mapped : null;
|
||
}
|
||
|
||
function normalizeItemOrganization(item, availableShelves = shelves || [], idMap = null) {
|
||
if (!item || typeof item !== 'object') return item;
|
||
return {
|
||
...item,
|
||
tags: normalizeTags(item.tags),
|
||
shelfId: normalizeShelfId(item.shelfId, availableShelves, idMap)
|
||
};
|
||
}
|
||
|
||
function parseShelfName(input) {
|
||
const value = typeof input === 'string' ? input : input && input.name;
|
||
if (typeof value !== 'string' || !value.trim()) throw new Error('书架名称不能为空');
|
||
return value.trim();
|
||
}
|
||
|
||
function parseTagName(input) {
|
||
const value = typeof input === 'string' ? input : input && input.name;
|
||
if (typeof value !== 'string' || !value.trim()) throw new Error('标签名称不能为空');
|
||
const name = value.trim();
|
||
if (Array.from(name).length > MAX_TAG_LENGTH) {
|
||
throw new Error(`标签名称不能超过 ${MAX_TAG_LENGTH} 个字符`);
|
||
}
|
||
return name;
|
||
}
|
||
|
||
// --- 封面缓存 ---
|
||
|
||
function isRemoteCover(c) { return typeof c === 'string' && /^https?:\/\//i.test(c); }
|
||
|
||
function coverStem(id) {
|
||
const value = String(id || '');
|
||
if (/^[a-z0-9_-]{1,128}$/i.test(value)) return value;
|
||
return crypto.createHash('sha256').update(value).digest('hex');
|
||
}
|
||
|
||
function imageExt(bytes) {
|
||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return '.jpg';
|
||
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return '.png';
|
||
if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('ascii'))) return '.gif';
|
||
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF'
|
||
&& bytes.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp';
|
||
if (bytes.length >= 2 && bytes.subarray(0, 2).toString('ascii') === 'BM') return '.bmp';
|
||
if (bytes.length >= 12 && bytes.subarray(4, 12).toString('ascii').startsWith('ftyp')
|
||
&& /avif|avis/.test(bytes.subarray(8, 16).toString('ascii'))) return '.avif';
|
||
const head = bytes.subarray(0, Math.min(bytes.length, 1024)).toString('utf8').replace(/^\uFEFF/, '').trimStart();
|
||
if (/^(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(head)) return '.svg';
|
||
return '';
|
||
}
|
||
|
||
async function responseBytes(res, maxBytes) {
|
||
const declared = Number(res.headers && res.headers.get && res.headers.get('content-length'));
|
||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
|
||
return null;
|
||
}
|
||
if (!res.body || typeof res.body.getReader !== 'function') {
|
||
const bytes = Buffer.from(await res.arrayBuffer());
|
||
return bytes.length <= maxBytes ? bytes : null;
|
||
}
|
||
const reader = res.body.getReader();
|
||
const chunks = [];
|
||
let size = 0;
|
||
try {
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
const chunk = Buffer.from(value);
|
||
size += chunk.length;
|
||
if (size > maxBytes) {
|
||
await reader.cancel();
|
||
return null;
|
||
}
|
||
chunks.push(chunk);
|
||
}
|
||
} finally {
|
||
try { reader.releaseLock(); } catch (e) { /* ignore */ }
|
||
}
|
||
return Buffer.concat(chunks, size);
|
||
}
|
||
|
||
async function cacheCover(id, url, baseDir) {
|
||
let temp = '';
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 15000);
|
||
try {
|
||
const res = await fetchWithProxy(url, {
|
||
headers: { 'User-Agent': DL_UA, 'Referer': new URL(url).origin },
|
||
signal: controller.signal
|
||
});
|
||
if (!res.ok) {
|
||
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
|
||
return '';
|
||
}
|
||
const buf = await responseBytes(res, 10 * 1024 * 1024);
|
||
if (!buf || !buf.length) return '';
|
||
const ext = imageExt(buf);
|
||
if (!ext) return '';
|
||
const dir = path.join(baseDir, 'covers');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const dest = path.join(
|
||
dir,
|
||
`${coverStem(id)}.source-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`
|
||
);
|
||
temp = `${dest}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
|
||
fs.writeFileSync(temp, buf, { flag: 'wx' });
|
||
fs.renameSync(temp, dest);
|
||
temp = '';
|
||
return dest;
|
||
} catch (e) {
|
||
if (temp) {
|
||
try { fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
|
||
}
|
||
return '';
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function ensureCoverCached(id) {
|
||
const raw = load().find((x) => x.id === id);
|
||
if (!raw || !isRemoteCover(raw.cover)) return '';
|
||
const original = raw.cover;
|
||
const baseDir = rootDir;
|
||
const key = `${baseDir}\0${id}\0${original}`;
|
||
if (coverCacheJobs.has(key)) return coverCacheJobs.get(key);
|
||
const job = (async () => {
|
||
const local = await cacheCover(id, original, baseDir);
|
||
if (!local) return '';
|
||
if (rootDir !== baseDir) {
|
||
try { fs.unlinkSync(local); } catch (e) { /* ignore */ }
|
||
return '';
|
||
}
|
||
const still = load().find((x) => x.id === id);
|
||
if (!still || still.cover !== original) {
|
||
try { fs.unlinkSync(local); } catch (e) { /* ignore */ }
|
||
return '';
|
||
}
|
||
const next = { ...still, cover: toRelative(local), updatedAt: Date.now() };
|
||
const nextItems = load().map((x) => x.id === id ? next : x);
|
||
commit(nextItems, true);
|
||
removeCoverFile(id, local);
|
||
return local;
|
||
})().finally(() => coverCacheJobs.delete(key));
|
||
coverCacheJobs.set(key, job);
|
||
return job;
|
||
}
|
||
|
||
function setGeneratedCover(id, bytes, expectedCover = '') {
|
||
load();
|
||
const raw = items.find((x) => x.id === id);
|
||
if (!raw) return '';
|
||
const current = toAbsolute(raw.cover);
|
||
if ((raw.cover || '') !== expectedCover && (current || '') !== expectedCover) return '';
|
||
|
||
const buf = Buffer.from(bytes || []);
|
||
if (buf.length < 4 || buf.length > 2 * 1024 * 1024
|
||
|| buf[0] !== 0xff || buf[1] !== 0xd8 || buf[2] !== 0xff) {
|
||
throw new Error('生成的封面不是有效的 JPEG');
|
||
}
|
||
|
||
fs.mkdirSync(coversDir(), { recursive: true });
|
||
const stem = coverStem(id);
|
||
let dest = path.join(coversDir(), `${stem}.generated.jpg`);
|
||
let n = 1;
|
||
while (fs.existsSync(dest)) dest = path.join(coversDir(), `${stem}.generated-${n++}.jpg`);
|
||
const temp = `${dest}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
|
||
fs.writeFileSync(temp, buf, { flag: 'wx' });
|
||
try {
|
||
fs.renameSync(temp, dest);
|
||
const next = { ...raw, cover: toRelative(dest), updatedAt: Date.now() };
|
||
commit(items.map((x) => x.id === id ? next : x), true);
|
||
} catch (e) {
|
||
try { fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
|
||
try { fs.unlinkSync(dest); } catch (cleanupError) { /* ignore */ }
|
||
throw e;
|
||
}
|
||
removeCoverFile(id, dest);
|
||
return dest;
|
||
}
|
||
|
||
function removeCoverFile(id, keep = '') {
|
||
try {
|
||
const dir = coversDir();
|
||
if (!fs.existsSync(dir)) return;
|
||
const stems = new Set([String(id), coverStem(id)]);
|
||
for (const f of fs.readdirSync(dir)) {
|
||
const target = path.join(dir, f);
|
||
if (keep && path.resolve(target) === path.resolve(keep)) continue;
|
||
if ([...stems].some((stem) => f === stem || f.startsWith(stem + '.'))) {
|
||
try { fs.unlinkSync(target); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// --- 查询 ---
|
||
|
||
function list() {
|
||
return load().slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)).map(expand);
|
||
}
|
||
|
||
function get(id) {
|
||
const it = load().find((x) => x.id === id);
|
||
return it ? expand(it) : null;
|
||
}
|
||
|
||
function findBySource(sourceId, sourcePostId) {
|
||
const it = load().find((x) => x.sourceId === sourceId && String(x.sourcePostId) === String(sourcePostId));
|
||
return it ? expand(it) : null;
|
||
}
|
||
|
||
function listShelves() {
|
||
load();
|
||
return shelves.map((shelf) => ({ ...shelf }));
|
||
}
|
||
|
||
function listTags() {
|
||
load();
|
||
const counts = new Map();
|
||
for (const item of items) {
|
||
for (const name of normalizeTags(item.tags)) {
|
||
const key = tagKey(name);
|
||
counts.set(key, (counts.get(key) || 0) + 1);
|
||
}
|
||
}
|
||
return tags.map((tag) => ({ ...tag, count: counts.get(tagKey(tag.name)) || 0 }))
|
||
.sort((a, b) => b.count - a.count
|
||
|| a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' })
|
||
|| a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'variant' })
|
||
|| a.id.localeCompare(b.id));
|
||
}
|
||
|
||
// --- 增删改 ---
|
||
|
||
function normalizeFile(f) {
|
||
const abs = toAbsolute(f.path);
|
||
return {
|
||
path: toRelative(abs),
|
||
name: f.name || path.basename(abs),
|
||
format: (f.format || path.extname(abs).slice(1) || '').toUpperCase()
|
||
};
|
||
}
|
||
|
||
function localPathKey(value) {
|
||
const resolved = path.resolve(value);
|
||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||
}
|
||
|
||
function canonicalLocalFile(value, allowSymlink = false) {
|
||
let stat;
|
||
try {
|
||
const before = fs.lstatSync(value);
|
||
if ((!before.isFile() && !before.isSymbolicLink())
|
||
|| (!allowSymlink && before.isSymbolicLink())) return null;
|
||
const canonical = path.resolve(fs.realpathSync(value));
|
||
const after = fs.lstatSync(value);
|
||
if ((!after.isFile() && !after.isSymbolicLink())
|
||
|| (!allowSymlink && after.isSymbolicLink())) return null;
|
||
stat = fs.statSync(canonical);
|
||
if (!stat.isFile()) return null;
|
||
return { path: canonical, size: stat.size };
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function hashLocalFile(value, expectedSize) {
|
||
const hash = crypto.createHash('sha256');
|
||
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||
let fd;
|
||
let total = 0;
|
||
try {
|
||
fd = fs.openSync(value, 'r');
|
||
for (;;) {
|
||
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||
if (!count) break;
|
||
hash.update(buffer.subarray(0, count));
|
||
total += count;
|
||
}
|
||
if (total !== expectedSize) return null;
|
||
return hash.digest('hex');
|
||
} catch (e) {
|
||
return null;
|
||
} finally {
|
||
if (fd !== undefined) {
|
||
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
function add(item) {
|
||
load();
|
||
const now = Date.now();
|
||
const it = {
|
||
id: genId(),
|
||
title: item.title || '未命名',
|
||
authors: item.authors || [],
|
||
cover: item.cover || '',
|
||
date: item.date || '',
|
||
brief: item.brief || '',
|
||
url: item.url || '',
|
||
tags: normalizeTags(item.tags),
|
||
shelfId: normalizeShelfId(item.shelfId),
|
||
sourceId: item.sourceId || null,
|
||
sourcePostId: item.sourcePostId != null ? String(item.sourcePostId) : null,
|
||
files: (item.files || []).map(normalizeFile),
|
||
addedAt: now,
|
||
updatedAt: now
|
||
};
|
||
const nextItems = [...items, it];
|
||
const organizationChanged = Object.prototype.hasOwnProperty.call(item, 'tags')
|
||
|| Object.prototype.hasOwnProperty.call(item, 'shelfId');
|
||
commit(nextItems, organizationChanged);
|
||
if (isRemoteCover(it.cover)) ensureCoverCached(it.id).catch(() => {});
|
||
return expand(it);
|
||
}
|
||
|
||
function importLocal(records, organization = 'none') {
|
||
load();
|
||
if (!['none', 'shelf', 'tag'].includes(organization)) {
|
||
throw new Error('本地导入分类方式无效');
|
||
}
|
||
const values = Array.isArray(records) ? records : [];
|
||
const knownPaths = new Set();
|
||
const existingBySize = new Map();
|
||
const existingHashCache = new Map();
|
||
for (const item of items) {
|
||
for (const file of item.files || []) {
|
||
if (!file || !file.path) continue;
|
||
const existingAbs = toAbsolute(file.path);
|
||
const existingExt = path.extname(existingAbs).slice(1).toLowerCase();
|
||
if (!BOOK_EXT.has(existingExt)) continue;
|
||
const existing = canonicalLocalFile(existingAbs, true);
|
||
if (!existing) continue;
|
||
const key = localPathKey(existing.path);
|
||
knownPaths.add(key);
|
||
if (!existingBySize.has(existing.size)) existingBySize.set(existing.size, []);
|
||
if (!existingBySize.get(existing.size).some((entry) => entry.key === key)) {
|
||
existingBySize.get(existing.size).push({ ...existing, key });
|
||
}
|
||
}
|
||
}
|
||
const acceptedBySize = new Map();
|
||
const acceptedHashCache = new Map();
|
||
|
||
const nextShelves = shelves.slice();
|
||
const nextTags = tags.slice();
|
||
const usedShelfIds = new Set(nextShelves.map((entry) => entry.id));
|
||
const usedTagIds = new Set(nextTags.map((entry) => entry.id));
|
||
const addedItems = [];
|
||
let skipped = 0;
|
||
let skippedDuplicates = 0;
|
||
|
||
for (const record of values) {
|
||
if (!record || typeof record !== 'object' || !record.path) { skipped++; continue; }
|
||
const abs = path.resolve(String(record.path));
|
||
const ext = path.extname(abs).slice(1).toLowerCase();
|
||
if (!BOOK_EXT.has(ext)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
const candidate = canonicalLocalFile(abs);
|
||
if (!candidate) { skipped++; continue; }
|
||
const candidateKey = localPathKey(candidate.path);
|
||
if (knownPaths.has(candidateKey)) {
|
||
skipped++;
|
||
skippedDuplicates++;
|
||
continue;
|
||
}
|
||
|
||
const possibleDuplicates = [
|
||
...(existingBySize.get(candidate.size) || []).map((entry) => ({
|
||
...entry,
|
||
cache: existingHashCache
|
||
})),
|
||
...(acceptedBySize.get(candidate.size) || []).map((entry) => ({
|
||
...entry,
|
||
cache: acceptedHashCache
|
||
}))
|
||
];
|
||
let candidateHash = null;
|
||
let duplicateBytes = false;
|
||
if (possibleDuplicates.length) {
|
||
candidateHash = hashLocalFile(candidate.path, candidate.size);
|
||
if (!candidateHash) { skipped++; continue; }
|
||
for (const possible of possibleDuplicates) {
|
||
let possibleHash = possible.cache.get(possible.key);
|
||
if (possibleHash === undefined) {
|
||
possibleHash = hashLocalFile(possible.path, possible.size);
|
||
possible.cache.set(possible.key, possibleHash);
|
||
}
|
||
if (possibleHash && possibleHash === candidateHash) {
|
||
duplicateBytes = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (duplicateBytes) {
|
||
skipped++;
|
||
skippedDuplicates++;
|
||
continue;
|
||
}
|
||
knownPaths.add(candidateKey);
|
||
if (!acceptedBySize.has(candidate.size)) acceptedBySize.set(candidate.size, []);
|
||
acceptedBySize.get(candidate.size).push({ ...candidate, key: candidateKey });
|
||
if (candidateHash) acceptedHashCache.set(candidateKey, candidateHash);
|
||
|
||
const now = Date.now();
|
||
const parentName = String(
|
||
record.parentName || path.basename(path.dirname(candidate.path))
|
||
).trim();
|
||
let shelfId = null;
|
||
let itemTags = [];
|
||
if (organization === 'shelf' && parentName) {
|
||
let shelf = nextShelves.find((entry) =>
|
||
shelfNameKey(entry.name) === shelfNameKey(parentName));
|
||
if (!shelf) {
|
||
shelf = {
|
||
id: genShelfId(usedShelfIds),
|
||
name: parentName,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
usedShelfIds.add(shelf.id);
|
||
nextShelves.push(shelf);
|
||
}
|
||
shelfId = shelf.id;
|
||
} else if (organization === 'tag' && parentName) {
|
||
const name = normalizeTags([parentName])[0];
|
||
if (name) {
|
||
let tag = nextTags.find((entry) => tagKey(entry.name) === tagKey(name));
|
||
if (!tag) {
|
||
tag = {
|
||
id: genTagId(usedTagIds),
|
||
name,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
usedTagIds.add(tag.id);
|
||
nextTags.push(tag);
|
||
}
|
||
itemTags = [tag.name];
|
||
}
|
||
}
|
||
|
||
const name = String(record.name || path.basename(candidate.path));
|
||
const title = String(record.title || path.basename(name, path.extname(name)) || '未命名').trim();
|
||
const authors = Array.isArray(record.authors)
|
||
? record.authors.map((author) => String(author || '').trim()).filter(Boolean)
|
||
: [];
|
||
addedItems.push({
|
||
id: genId(),
|
||
title: title || '未命名',
|
||
authors,
|
||
cover: '',
|
||
date: '',
|
||
brief: '',
|
||
url: '',
|
||
tags: itemTags,
|
||
shelfId,
|
||
sourceId: null,
|
||
sourcePostId: null,
|
||
files: [normalizeFile({ path: candidate.path, name, format: ext })],
|
||
addedAt: now,
|
||
updatedAt: now,
|
||
importedByLocal: true
|
||
});
|
||
}
|
||
|
||
if (addedItems.length) {
|
||
commit([...items, ...addedItems], true, nextShelves, nextTags);
|
||
}
|
||
return {
|
||
added: addedItems.length,
|
||
skipped,
|
||
skippedDuplicates,
|
||
items: addedItems.map(expand)
|
||
};
|
||
}
|
||
|
||
function update(id, patch) {
|
||
load();
|
||
const it = items.find((x) => x.id === id);
|
||
if (!it) throw new Error('条目不存在');
|
||
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')
|
||
|| Object.prototype.hasOwnProperty.call(next, 'shelfId');
|
||
commit(nextItems, organizationChanged);
|
||
return expand(updated);
|
||
}
|
||
|
||
function addShelf(input) {
|
||
load();
|
||
const name = parseShelfName(input);
|
||
if (shelves.some((shelf) => shelfNameKey(shelf.name) === shelfNameKey(name))) {
|
||
throw new Error('书架名称已存在');
|
||
}
|
||
const now = Date.now();
|
||
const shelf = {
|
||
id: genShelfId(new Set(shelves.map((entry) => entry.id))),
|
||
name,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
commit(items, true, [...shelves, shelf]);
|
||
return { ...shelf };
|
||
}
|
||
|
||
function updateShelf(id, patch) {
|
||
load();
|
||
const shelf = shelves.find((entry) => entry.id === id);
|
||
if (!shelf) throw new Error('书架不存在');
|
||
const name = patch && Object.prototype.hasOwnProperty.call(patch, 'name')
|
||
? parseShelfName(patch)
|
||
: shelf.name;
|
||
if (shelves.some((entry) => entry.id !== id
|
||
&& shelfNameKey(entry.name) === shelfNameKey(name))) {
|
||
throw new Error('书架名称已存在');
|
||
}
|
||
const updated = { ...shelf, name, updatedAt: Date.now() };
|
||
commit(items, true, shelves.map((entry) => entry.id === id ? updated : entry));
|
||
return { ...updated };
|
||
}
|
||
|
||
function removeShelf(id) {
|
||
load();
|
||
if (!shelves.some((entry) => entry.id === id)) return { removed: false };
|
||
const now = Date.now();
|
||
const nextItems = items.map((item) => item.shelfId === id
|
||
? { ...item, shelfId: null, updatedAt: now }
|
||
: item);
|
||
commit(nextItems, true, shelves.filter((entry) => entry.id !== id));
|
||
return { removed: true };
|
||
}
|
||
|
||
function addTag(input) {
|
||
load();
|
||
const name = parseTagName(input);
|
||
if (tags.some((tag) => tagKey(tag.name) === tagKey(name))) {
|
||
throw new Error('标签名称已存在');
|
||
}
|
||
if (tags.length >= MAX_TAGS) {
|
||
throw new Error(`标签数量不能超过 ${MAX_TAGS} 个`);
|
||
}
|
||
const now = Date.now();
|
||
const tag = {
|
||
id: genTagId(new Set(tags.map((entry) => entry.id))),
|
||
name,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
commit(items, true, shelves, [...tags, tag]);
|
||
return { ...tag };
|
||
}
|
||
|
||
function updateTag(id, patch) {
|
||
load();
|
||
const tag = tags.find((entry) => entry.id === id);
|
||
if (!tag) throw new Error('标签不存在');
|
||
const name = patch && Object.prototype.hasOwnProperty.call(patch, 'name')
|
||
? parseTagName(patch)
|
||
: tag.name;
|
||
if (tags.some((entry) => entry.id !== id && tagKey(entry.name) === tagKey(name))) {
|
||
throw new Error('标签名称已存在');
|
||
}
|
||
|
||
const now = Date.now();
|
||
const oldKey = tagKey(tag.name);
|
||
const updated = { ...tag, name, updatedAt: now };
|
||
const nextItems = items.map((item) => {
|
||
const itemTags = normalizeTags(item.tags);
|
||
if (!itemTags.some((itemTag) => tagKey(itemTag) === oldKey)) return item;
|
||
return {
|
||
...item,
|
||
tags: normalizeTags(itemTags.map((itemTag) => tagKey(itemTag) === oldKey ? name : itemTag)),
|
||
updatedAt: now
|
||
};
|
||
});
|
||
commit(
|
||
nextItems,
|
||
true,
|
||
shelves,
|
||
tags.map((entry) => entry.id === id ? updated : entry)
|
||
);
|
||
return { ...updated };
|
||
}
|
||
|
||
function removeTag(id) {
|
||
load();
|
||
const tag = tags.find((entry) => entry.id === id);
|
||
if (!tag) return { removed: false };
|
||
const now = Date.now();
|
||
const key = tagKey(tag.name);
|
||
const nextItems = items.map((item) => {
|
||
const itemTags = normalizeTags(item.tags);
|
||
const remaining = itemTags.filter((itemTag) => tagKey(itemTag) !== key);
|
||
return remaining.length === itemTags.length
|
||
? item
|
||
: { ...item, tags: remaining, updatedAt: now };
|
||
});
|
||
commit(nextItems, true, shelves, tags.filter((entry) => entry.id !== id));
|
||
return { removed: true };
|
||
}
|
||
|
||
function attachFile(id, filePath) {
|
||
load();
|
||
const it = items.find((x) => x.id === id);
|
||
if (!it) return null;
|
||
const f = normalizeFile({ path: filePath });
|
||
const updated = {
|
||
...it,
|
||
files: [...(it.files || []).filter((x) => x.path !== f.path), f],
|
||
updatedAt: Date.now()
|
||
};
|
||
const nextItems = items.map((x) => x.id === id ? updated : x);
|
||
commit(nextItems, true);
|
||
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);
|
||
const nextItems = items.filter((x) => x.id !== id);
|
||
const staged = [];
|
||
if (deleteFiles && it) {
|
||
try {
|
||
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);
|
||
} 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) { /* 文件已从书库移除,稍后可手动清理 */ }
|
||
}
|
||
removeCoverFile(id);
|
||
return { removed: true };
|
||
}
|
||
|
||
// --- 下载落地 ---
|
||
|
||
function sanitize(name) {
|
||
return String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim() || 'download';
|
||
}
|
||
|
||
// 在 files/ 下分配一个不冲突的路径
|
||
function allocFilePath(name) {
|
||
fs.mkdirSync(filesDir(), { recursive: true });
|
||
const safe = sanitize(name);
|
||
const ext = path.extname(safe);
|
||
const base = ext ? safe.slice(0, -ext.length) : safe;
|
||
let candidate = path.join(filesDir(), safe);
|
||
let n = 1;
|
||
while (fs.existsSync(candidate)) candidate = path.join(filesDir(), `${base} (${n++})${ext}`);
|
||
return candidate;
|
||
}
|
||
|
||
// --- 扫描 ---
|
||
|
||
// 比对 files/ 与索引:孤立文件补建条目;记录缺失的文件不删元数据(可能带用户标签)
|
||
function scan() {
|
||
load();
|
||
ensureDirs();
|
||
|
||
const known = new Set();
|
||
for (const it of items) {
|
||
for (const f of it.files || []) known.add(toAbsolute(f.path));
|
||
}
|
||
|
||
let added = 0;
|
||
let entries = [];
|
||
try { entries = fs.readdirSync(filesDir(), { withFileTypes: true }); } catch (e) { entries = []; }
|
||
|
||
const discovered = [];
|
||
for (const e of entries) {
|
||
if (!e.isFile()) continue;
|
||
const staged = e.name.match(/^(.*)\.deleting-[a-z0-9]+$/i);
|
||
if (staged) {
|
||
const temp = path.join(filesDir(), e.name);
|
||
const original = path.join(filesDir(), staged[1]);
|
||
try {
|
||
if (known.has(original) && !fs.existsSync(original)) fs.renameSync(temp, original);
|
||
else fs.unlinkSync(temp);
|
||
} catch (cleanupError) { /* 下次扫描重试 */ }
|
||
continue;
|
||
}
|
||
const abs = path.join(filesDir(), e.name);
|
||
if (known.has(abs)) continue;
|
||
const ext = path.extname(e.name).slice(1).toLowerCase();
|
||
if (!BOOK_EXT.has(ext)) continue;
|
||
const now = Date.now();
|
||
discovered.push({
|
||
id: genId(),
|
||
title: path.basename(e.name, path.extname(e.name)),
|
||
authors: [],
|
||
cover: '',
|
||
date: '',
|
||
brief: '',
|
||
url: '',
|
||
tags: [],
|
||
shelfId: null,
|
||
sourceId: null,
|
||
sourcePostId: null,
|
||
files: [normalizeFile({ path: abs })],
|
||
addedAt: now,
|
||
updatedAt: now,
|
||
importedByScan: true
|
||
});
|
||
added++;
|
||
}
|
||
|
||
const nextItems = discovered.length ? [...items, ...discovered] : items;
|
||
let missing = 0;
|
||
for (const it of nextItems) {
|
||
const has = (it.files || []).length > 0;
|
||
if (has && (it.files || []).every((f) => !fs.existsSync(toAbsolute(f.path)))) missing++;
|
||
}
|
||
|
||
if (added) {
|
||
commit(nextItems, true);
|
||
}
|
||
return { added, missing, total: items.length };
|
||
}
|
||
|
||
// --- 目录迁移 ---
|
||
|
||
// 把当前书库目录整体搬到 dest(含 library.json / files / covers)
|
||
function migrateTo(dest) {
|
||
if (pendingMigration) throw new Error('上一次书库迁移尚未完成');
|
||
const src = path.resolve(rootDir);
|
||
dest = path.resolve(dest);
|
||
if (!src || src === dest) return { moved: 0 };
|
||
if (isWithin(src, dest) || isWithin(dest, src)) throw new Error('新旧书库目录不能互相包含');
|
||
load();
|
||
|
||
const destIndex = path.join(dest, 'library.json');
|
||
for (const suffix of ['', '.bak', '.tmp']) {
|
||
if (fs.existsSync(`${destIndex}${suffix}`)) {
|
||
throw new Error('目标目录已包含书库索引或备份,请选择“直接切换”或使用空目录');
|
||
}
|
||
}
|
||
for (const sub of ['files', 'covers']) {
|
||
const targetDir = path.join(dest, sub);
|
||
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length) {
|
||
throw new Error(`目标目录的 ${sub} 子目录不为空,请使用空目录`);
|
||
}
|
||
}
|
||
|
||
// 相对路径不受影响;绝对路径若指向旧书库目录内,需重定位
|
||
const migratedItems = items.map((it) => {
|
||
const next = {
|
||
...it,
|
||
files: (it.files || []).map((f) => {
|
||
if (!path.isAbsolute(f.path)) return f;
|
||
const abs = path.resolve(f.path);
|
||
if (!isWithin(src, abs)) return f;
|
||
return { ...f, path: path.relative(src, abs).split(path.sep).join('/') };
|
||
})
|
||
};
|
||
if (next.cover && path.isAbsolute(next.cover) && isWithin(src, next.cover)) {
|
||
next.cover = path.relative(src, next.cover).split(path.sep).join('/');
|
||
}
|
||
return next;
|
||
});
|
||
|
||
const copied = [];
|
||
try {
|
||
for (const sub of ['files', 'covers']) {
|
||
const from = path.join(src, sub);
|
||
const to = path.join(dest, sub);
|
||
fs.mkdirSync(to, { recursive: true });
|
||
if (!fs.existsSync(from)) continue;
|
||
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
||
if (!entry.isFile()) continue;
|
||
const target = path.join(to, entry.name);
|
||
fs.copyFileSync(path.join(from, entry.name), target, fs.constants.COPYFILE_EXCL);
|
||
copied.push({ source: path.join(from, entry.name), target });
|
||
}
|
||
}
|
||
persistTo(dest, migratedItems, shelves, tags);
|
||
} catch (e) {
|
||
for (const f of copied) {
|
||
try { fs.unlinkSync(f.target); } catch (cleanupError) { /* ignore */ }
|
||
}
|
||
throw e;
|
||
}
|
||
|
||
items = migratedItems;
|
||
rootDir = dest;
|
||
pendingMigration = { src, dest, copied };
|
||
return { moved: copied.length };
|
||
}
|
||
|
||
function finalizeMigration() {
|
||
if (!pendingMigration) return;
|
||
const { src, copied } = pendingMigration;
|
||
pendingMigration = null;
|
||
for (const f of copied) {
|
||
try { fs.unlinkSync(f.source); } catch (e) { /* 保留重复副本 */ }
|
||
}
|
||
try { fs.unlinkSync(path.join(src, 'library.json')); } catch (e) { /* 保留旧索引副本 */ }
|
||
}
|
||
|
||
function rollbackMigration() {
|
||
if (!pendingMigration) return;
|
||
const { src, dest, copied } = pendingMigration;
|
||
pendingMigration = null;
|
||
rootDir = src;
|
||
items = null;
|
||
shelves = null;
|
||
tags = null;
|
||
for (const f of copied) {
|
||
try { fs.unlinkSync(f.target); } catch (e) { /* ignore */ }
|
||
}
|
||
for (const suffix of ['', '.bak', '.tmp']) {
|
||
try { fs.unlinkSync(path.join(dest, `library.json${suffix}`)); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
// 从旧版本(元数据直接放在 userData 根目录、文件记绝对路径)导入一次
|
||
function importLegacy(legacyDir) {
|
||
const legacyIndex = path.join(legacyDir, 'library.json');
|
||
if (!fs.existsSync(legacyIndex)) return { imported: 0 };
|
||
if (path.resolve(legacyDir) === path.resolve(rootDir)) return { imported: 0 };
|
||
|
||
let legacyItems = [];
|
||
let legacyShelves = [];
|
||
let legacyTags = [];
|
||
try {
|
||
const raw = JSON.parse(fs.readFileSync(legacyIndex, 'utf-8'));
|
||
legacyItems = Array.isArray(raw) ? raw : (raw && raw.items) || [];
|
||
legacyShelves = Array.isArray(raw && raw.shelves) ? raw.shelves : [];
|
||
legacyTags = Array.isArray(raw && raw.tags) ? raw.tags : [];
|
||
} catch (e) { return { imported: 0 }; }
|
||
if (!legacyItems.length && !legacyShelves.length && !legacyTags.length) return { imported: 0 };
|
||
|
||
load();
|
||
const normalizedLegacyShelves = normalizeStoredShelves(legacyShelves);
|
||
const nextShelves = shelves.slice();
|
||
const shelfIdMap = new Map();
|
||
const usedShelfIds = new Set(nextShelves.map((shelf) => shelf.id));
|
||
for (const oldShelf of normalizedLegacyShelves.value) {
|
||
const sameName = nextShelves.find((shelf) =>
|
||
shelfNameKey(shelf.name) === shelfNameKey(oldShelf.name));
|
||
if (sameName) {
|
||
shelfIdMap.set(oldShelf.id, sameName.id);
|
||
continue;
|
||
}
|
||
const id = usedShelfIds.has(oldShelf.id) ? genShelfId(usedShelfIds) : oldShelf.id;
|
||
nextShelves.push({ ...oldShelf, id });
|
||
usedShelfIds.add(id);
|
||
shelfIdMap.set(oldShelf.id, id);
|
||
}
|
||
|
||
const normalizedLegacyTags = normalizeStoredTags(legacyTags);
|
||
const nextTags = tags.slice();
|
||
const usedTagIds = new Set(nextTags.map((tag) => tag.id));
|
||
for (const oldTag of normalizedLegacyTags) {
|
||
if (nextTags.some((tag) => tagKey(tag.name) === tagKey(oldTag.name))) continue;
|
||
const id = usedTagIds.has(oldTag.id) ? genTagId(usedTagIds) : oldTag.id;
|
||
nextTags.push({ ...oldTag, id });
|
||
usedTagIds.add(id);
|
||
}
|
||
|
||
const legacyKey = (x, baseDir) => {
|
||
if (x.sourceId && x.sourcePostId != null) return `source:${x.sourceId}|${x.sourcePostId}`;
|
||
const filePaths = (x.files || [])
|
||
.filter((f) => f && f.path)
|
||
.map((f) => path.resolve(baseDir, f.path))
|
||
.sort();
|
||
if (filePaths.length) return `files:${filePaths.join('|')}`;
|
||
if (x.id) return `id:${x.id}`;
|
||
return `meta:${x.title || ''}|${(x.authors || []).join('|')}|${x.addedAt || ''}`;
|
||
};
|
||
const seen = new Set(items.map((x) => legacyKey(x, rootDir)));
|
||
|
||
// 旧封面在 <legacy>/covers,搬进新书库
|
||
const legacyCovers = path.join(legacyDir, 'covers');
|
||
let imported = 0;
|
||
const importedItems = [];
|
||
for (const old of legacyItems) {
|
||
const key = legacyKey(old, legacyDir);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
|
||
let cover = old.cover || '';
|
||
const legacyCover = cover && !isRemoteCover(cover)
|
||
? (path.isAbsolute(cover) ? cover : path.resolve(legacyDir, cover))
|
||
: '';
|
||
if (legacyCover && fs.existsSync(legacyCover) && isWithin(legacyCovers, legacyCover)) {
|
||
const dest = path.join(coversDir(), path.basename(legacyCover));
|
||
try {
|
||
fs.mkdirSync(coversDir(), { recursive: true });
|
||
fs.copyFileSync(legacyCover, dest);
|
||
cover = toRelative(dest);
|
||
} catch (e) {
|
||
cover = legacyCover;
|
||
}
|
||
}
|
||
|
||
const now = Date.now();
|
||
importedItems.push({
|
||
id: old.id || genId(),
|
||
title: old.title || '未命名',
|
||
authors: old.authors || [],
|
||
cover,
|
||
date: old.date || '',
|
||
brief: old.brief || '',
|
||
url: old.url || '',
|
||
tags: normalizeTags(old.tags),
|
||
shelfId: shelfIdMap.get(normalizeShelfId(
|
||
old.shelfId,
|
||
normalizedLegacyShelves.value,
|
||
normalizedLegacyShelves.idMap
|
||
)) || null,
|
||
sourceId: old.sourceId || null,
|
||
sourcePostId: old.sourcePostId != null ? String(old.sourcePostId) : null,
|
||
// 旧数据文件在用户自选位置,保持绝对路径原地引用
|
||
files: (old.files || []).filter((f) => f && f.path).map(normalizeFile),
|
||
addedAt: old.addedAt || now,
|
||
updatedAt: now
|
||
});
|
||
imported++;
|
||
}
|
||
|
||
if (imported || nextShelves.length !== shelves.length || nextTags.length !== tags.length) {
|
||
const nextItems = [...items, ...importedItems];
|
||
commit(nextItems, true, nextShelves, nextTags);
|
||
}
|
||
return { imported };
|
||
}
|
||
|
||
module.exports = {
|
||
init, getRoot, filesDir, allocFilePath, sanitize,
|
||
list, get, findBySource, listShelves, listTags,
|
||
add, importLocal, update, updateMany, remove, removeMany, attachFile,
|
||
addShelf, updateShelf, removeShelf,
|
||
addTag, updateTag, removeTag,
|
||
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
|
||
ensureCoverCached, setGeneratedCover, setChangeListener
|
||
};
|