feat: 完善本地书库与发布更新流程
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
de3e1d8a44
commit
b8c8d24107
+471
-52
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user