feat: 完善本地书库与发布更新流程

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-28 21:55:16 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent de3e1d8a44
commit b8c8d24107
22 changed files with 1579 additions and 359 deletions
+471 -52
View File
@@ -1,17 +1,144 @@
const { app } = require('electron');
// 本地书库:元数据 + 文件统一存放在「书库目录」下,用户可在设置中更改。
//
// <书库目录>/
// library.json 元数据索引(含 schema 版本)
// files/ 下载的书籍文件
// covers/ 封面缓存
//
// 路径存储约定:
// - 书库目录内的文件记相对路径(files/xxx.pdf),随书库目录整体迁移仍然有效
// - 用户「添加本地文件」时原地引用,记绝对路径,不复制
// 对外一律返回拼好的绝对路径,渲染层不需要关心这个区别。
const fs = require('fs');
const path = require('path');
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 FILE = () => path.join(app.getPath('userData'), 'library.json');
const COVER_DIR = () => path.join(app.getPath('userData'), 'covers');
const SCHEMA_VERSION = 2;
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr']);
let rootDir = null;
let items = null;
let changeListener = null;
let pendingMigration = null;
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;
}
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 }
if (Array.isArray(raw)) items = raw;
else if (raw && Array.isArray(raw.items)) items = raw.items;
else throw new Error('索引格式无效');
} catch (e) {
if (e && e.code === 'ENOENT') items = [];
else throw new Error(`书库索引读取失败: ${e.message || e}`);
}
return items;
}
function persistTo(dir, value) {
const dest = path.join(dir, 'library.json');
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(temp, JSON.stringify({ version: SCHEMA_VERSION, items: value }, null, 2), 'utf-8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (cleanupError) { /* 保留备份不影响提交 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollbackError) { /* 下次加载时会从 .bak 恢复 */ }
throw new Error(`书库索引写入失败: ${e.message || e}`);
}
}
function commit(nextItems, notify = false) {
persistTo(rootDir, nextItems);
items = nextItems;
if (notify) notifyChange();
}
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
// --- 封面缓存 ---
function isRemoteCover(c) { return typeof c === 'string' && /^https?:\/\//i.test(c); }
function coverExt(url) {
const m = String(url).split('?')[0].match(/\.(png|jpe?g|webp|gif|bmp)$/i);
return m ? m[0].toLowerCase() : '.img';
@@ -23,23 +150,29 @@ async function cacheCover(id, url) {
if (!res.ok) return '';
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) return '';
fs.mkdirSync(COVER_DIR(), { recursive: true });
const dest = path.join(COVER_DIR(), id + coverExt(url));
fs.mkdirSync(coversDir(), { recursive: true });
const dest = path.join(coversDir(), id + coverExt(url));
fs.writeFileSync(dest, buf);
return dest;
} catch (e) { return ''; }
}
async function ensureCoverCached(id) {
const it = get(id);
if (!it || !isRemoteCover(it.cover)) return;
const local = await cacheCover(id, it.cover);
if (local && get(id)) { update(id, { cover: local }); notifyChange(); }
const raw = load().find((x) => x.id === id);
if (!raw || !isRemoteCover(raw.cover)) return;
const local = await cacheCover(id, raw.cover);
if (!local) return;
const still = load().find((x) => x.id === id);
if (still) {
const next = { ...still, cover: toRelative(local), updatedAt: Date.now() };
const nextItems = load().map((x) => x.id === id ? next : x);
commit(nextItems, true);
}
}
function removeCoverFile(id) {
try {
const dir = COVER_DIR();
const dir = coversDir();
if (!fs.existsSync(dir)) return;
for (const f of fs.readdirSync(dir)) {
if (f === id || f.startsWith(id + '.')) { try { fs.unlinkSync(path.join(dir, f)); } catch (e) { /* ignore */ } }
@@ -47,37 +180,36 @@ function removeCoverFile(id) {
} catch (e) { /* ignore */ }
}
function setChangeListener(fn) { changeListener = typeof fn === 'function' ? fn : null; }
function notifyChange() { if (changeListener) { try { changeListener(); } catch (e) { /* ignore */ } } }
function load() {
if (items) return items;
try {
items = JSON.parse(fs.readFileSync(FILE(), 'utf-8'));
if (!Array.isArray(items)) items = [];
} catch (e) { items = []; }
return items;
}
function persist() {
fs.mkdirSync(path.dirname(FILE()), { recursive: true });
fs.writeFileSync(FILE(), JSON.stringify(items, null, 2), 'utf-8');
}
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
// --- 查询 ---
function list() {
return load().slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
return load().slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)).map(expand);
}
function get(id) { return load().find((x) => x.id === id) || null; }
function get(id) {
const it = load().find((x) => x.id === id);
return it ? expand(it) : null;
}
function findBySource(sourceId, sourcePostId) {
return load().find((x) => x.sourceId === sourceId && String(x.sourcePostId) === String(sourcePostId)) || null;
const it = load().find((x) => x.sourceId === sourceId && String(x.sourcePostId) === String(sourcePostId));
return it ? expand(it) : null;
}
// --- 增删改 ---
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 add(item) {
load();
const now = Date.now();
const it = {
id: genId(),
title: item.title || '未命名',
@@ -86,45 +218,332 @@ function add(item) {
date: item.date || '',
brief: item.brief || '',
url: item.url || '',
tags: item.tags || [],
sourceId: item.sourceId || null,
sourcePostId: item.sourcePostId != null ? String(item.sourcePostId) : null,
files: item.files || [], // [{ path, name, format }]
addedAt: Date.now()
files: (item.files || []).map(normalizeFile),
addedAt: now,
updatedAt: now
};
items.push(it);
persist();
if (isRemoteCover(it.cover)) ensureCoverCached(it.id);
return it;
const nextItems = [...items, it];
commit(nextItems);
if (isRemoteCover(it.cover)) ensureCoverCached(it.id).catch(() => {});
return expand(it);
}
function update(id, patch) {
load();
const it = items.find((x) => x.id === id);
if (!it) throw new Error('条目不存在');
Object.assign(it, patch);
persist();
return it;
const next = { ...patch };
if (next.files) next.files = next.files.map(normalizeFile);
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
const updated = { ...it, ...next, updatedAt: Date.now() };
const nextItems = items.map((x) => x.id === id ? updated : x);
commit(nextItems);
return expand(updated);
}
function attachFile(id, filePath) {
const it = get(id);
if (!it) return;
const files = (it.files || []).filter((f) => f.path !== filePath);
files.push({ path: filePath, name: path.basename(filePath), format: (path.extname(filePath) || '').slice(1).toUpperCase() });
update(id, { files });
notifyChange();
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 remove(id, deleteFiles) {
load();
const it = get(id);
if (deleteFiles && it && it.files) {
for (const f of it.files) { try { if (f.path && fs.existsSync(f.path)) fs.unlinkSync(f.path); } catch (e) { /* ignore */ } }
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) { /* 文件已从书库移除,稍后可手动清理 */ }
}
items = items.filter((x) => x.id !== id);
persist();
removeCoverFile(id);
return { removed: true };
}
module.exports = { list, get, findBySource, add, update, remove, attachFile, setChangeListener };
// --- 下载落地 ---
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: [],
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);
} 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;
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 = [];
try {
const raw = JSON.parse(fs.readFileSync(legacyIndex, 'utf-8'));
legacyItems = Array.isArray(raw) ? raw : (raw && raw.items) || [];
} catch (e) { return { imported: 0 }; }
if (!legacyItems.length) return { imported: 0 };
load();
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 || '';
if (cover && !isRemoteCover(cover) && fs.existsSync(cover) && isWithin(legacyCovers, cover)) {
const dest = path.join(coversDir(), path.basename(cover));
try { fs.mkdirSync(coversDir(), { recursive: true }); fs.copyFileSync(cover, dest); cover = toRelative(dest); }
catch (e) { /* 保留原绝对路径 */ }
}
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: old.tags || [],
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) {
const nextItems = [...items, ...importedItems];
commit(nextItems, true);
}
return { imported };
}
module.exports = {
init, getRoot, filesDir, allocFilePath, sanitize,
list, get, findBySource, add, update, remove, attachFile,
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy, setChangeListener
};