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

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

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

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

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

1322 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本地书库:元数据 + 文件统一存放在「书库目录」下,用户可在设置中更改。
//
// <书库目录>/
// 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 增加 shelvesv4 增加 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
};