// 本地书库:元数据 + 文件统一存放在「书库目录」下,用户可在设置中更改。 // // <书库目录>/ // 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 { UA: DL_UA, fetchWithProxy } = require('../sources/http'); 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*)?]/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(); const counts = new Map(); for (const item of items) { if (!item.shelfId) continue; counts.set(item.shelfId, (counts.get(item.shelfId) || 0) + 1); } return shelves.map((shelf) => ({ ...shelf, count: counts.get(shelf.id) || 0 })); } 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 removeMissing() { load(); const doomed = items.filter((item) => { const files = item.files || []; return files.length > 0 && files.every((file) => !fs.existsSync(toAbsolute(file.path))); }); if (!doomed.length) return { removed: 0, shelvesRemoved: 0, tagsRemoved: 0 }; const ids = new Set(doomed.map((item) => item.id)); const candidateShelves = new Set(doomed.map((item) => item.shelfId).filter(Boolean)); const candidateTags = new Set(doomed.flatMap((item) => normalizeTags(item.tags).map(tagKey))); const nextItems = items.filter((item) => !ids.has(item.id)); const usedShelves = new Set(nextItems.map((item) => item.shelfId).filter(Boolean)); const usedTags = new Set(nextItems.flatMap((item) => normalizeTags(item.tags).map(tagKey))); const nextShelves = shelves.filter((shelf) => ( !candidateShelves.has(shelf.id) || usedShelves.has(shelf.id) )); const nextTags = tags.filter((tag) => ( !candidateTags.has(tagKey(tag.name)) || usedTags.has(tagKey(tag.name)) )); const shelvesRemoved = shelves.length - nextShelves.length; const tagsRemoved = tags.length - nextTags.length; commit(nextItems, true, nextShelves, nextTags); for (const item of doomed) removeCoverFile(item.id); return { removed: doomed.length, shelvesRemoved, tagsRemoved }; } 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))); // 旧封面在 /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, removeMissing, attachFile, addShelf, updateShelf, removeShelf, addTag, updateTag, removeTag, scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy, ensureCoverCached, setGeneratedCover, setChangeListener };