feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0
新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件, 批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、 图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文, 页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。 便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化, 避免升级后 data/ 被遗留在旧目录。 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
b8c8d24107
commit
3ccd044527
+765
-45
@@ -12,17 +12,23 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
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 = 2;
|
||||
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr']);
|
||||
const SCHEMA_VERSION = 4;
|
||||
const MAX_TAGS = 50;
|
||||
const MAX_TAG_LENGTH = 64;
|
||||
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', '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 */ } } }
|
||||
@@ -36,6 +42,8 @@ function init(dir) {
|
||||
}
|
||||
rootDir = nextRoot;
|
||||
items = null;
|
||||
shelves = null;
|
||||
tags = null;
|
||||
}
|
||||
|
||||
function getRoot() { return rootDir; }
|
||||
@@ -90,25 +98,53 @@ function load() {
|
||||
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('索引格式无效');
|
||||
// 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 = [];
|
||||
if (e && e.code === 'ENOENT') {
|
||||
items = [];
|
||||
shelves = [];
|
||||
tags = [];
|
||||
}
|
||||
else throw new Error(`书库索引读取失败: ${e.message || e}`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function persistTo(dir, value) {
|
||||
function persistTo(dir, value, shelfValue = shelves || [], tagValue = tags || []) {
|
||||
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');
|
||||
fs.writeFileSync(
|
||||
temp,
|
||||
JSON.stringify({
|
||||
version: SCHEMA_VERSION,
|
||||
shelves: shelfValue,
|
||||
tags: tagValue,
|
||||
items: value
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.renameSync(dest, backup);
|
||||
@@ -127,55 +163,338 @@ function persistTo(dir, value) {
|
||||
}
|
||||
}
|
||||
|
||||
function commit(nextItems, notify = false) {
|
||||
persistTo(rootDir, nextItems);
|
||||
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 coverExt(url) {
|
||||
const m = String(url).split('?')[0].match(/\.(png|jpe?g|webp|gif|bmp)$/i);
|
||||
return m ? m[0].toLowerCase() : '.img';
|
||||
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');
|
||||
}
|
||||
|
||||
async function cacheCover(id, url) {
|
||||
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 {
|
||||
const res = await fetchWithProxy(url, { headers: { 'User-Agent': DL_UA, 'Referer': new URL(url).origin } });
|
||||
if (!res.ok) return '';
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (!buf.length) return '';
|
||||
fs.mkdirSync(coversDir(), { recursive: true });
|
||||
const dest = path.join(coversDir(), id + coverExt(url));
|
||||
fs.writeFileSync(dest, buf);
|
||||
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) { return ''; }
|
||||
} 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 local = await cacheCover(id, raw.cover);
|
||||
if (!local) return;
|
||||
const still = load().find((x) => x.id === id);
|
||||
if (still) {
|
||||
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 removeCoverFile(id) {
|
||||
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)) {
|
||||
if (f === id || f.startsWith(id + '.')) { try { fs.unlinkSync(path.join(dir, f)); } catch (e) { /* ignore */ } }
|
||||
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 */ }
|
||||
}
|
||||
@@ -196,6 +515,27 @@ function findBySource(sourceId, 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) {
|
||||
@@ -207,6 +547,53 @@ function normalizeFile(f) {
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -218,7 +605,8 @@ function add(item) {
|
||||
date: item.date || '',
|
||||
brief: item.brief || '',
|
||||
url: item.url || '',
|
||||
tags: item.tags || [],
|
||||
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),
|
||||
@@ -226,11 +614,176 @@ function add(item) {
|
||||
updatedAt: now
|
||||
};
|
||||
const nextItems = [...items, it];
|
||||
commit(nextItems);
|
||||
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);
|
||||
@@ -238,12 +791,129 @@ function update(id, 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);
|
||||
const updated = { ...it, ...next, updatedAt: Date.now() };
|
||||
const nextItems = items.map((x) => x.id === id ? updated : x);
|
||||
commit(nextItems);
|
||||
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);
|
||||
@@ -357,6 +1027,7 @@ function scan() {
|
||||
brief: '',
|
||||
url: '',
|
||||
tags: [],
|
||||
shelfId: null,
|
||||
sourceId: null,
|
||||
sourcePostId: null,
|
||||
files: [normalizeFile({ path: abs })],
|
||||
@@ -435,7 +1106,7 @@ function migrateTo(dest) {
|
||||
copied.push({ source: path.join(from, entry.name), target });
|
||||
}
|
||||
}
|
||||
persistTo(dest, migratedItems);
|
||||
persistTo(dest, migratedItems, shelves, tags);
|
||||
} catch (e) {
|
||||
for (const f of copied) {
|
||||
try { fs.unlinkSync(f.target); } catch (cleanupError) { /* ignore */ }
|
||||
@@ -465,6 +1136,8 @@ function rollbackMigration() {
|
||||
pendingMigration = null;
|
||||
rootDir = src;
|
||||
items = null;
|
||||
shelves = null;
|
||||
tags = null;
|
||||
for (const f of copied) {
|
||||
try { fs.unlinkSync(f.target); } catch (e) { /* ignore */ }
|
||||
}
|
||||
@@ -480,13 +1153,44 @@ function importLegacy(legacyDir) {
|
||||
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) 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 || [])
|
||||
@@ -509,10 +1213,18 @@ function importLegacy(legacyDir) {
|
||||
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 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();
|
||||
@@ -524,7 +1236,12 @@ function importLegacy(legacyDir) {
|
||||
date: old.date || '',
|
||||
brief: old.brief || '',
|
||||
url: old.url || '',
|
||||
tags: old.tags || [],
|
||||
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,
|
||||
// 旧数据文件在用户自选位置,保持绝对路径原地引用
|
||||
@@ -535,15 +1252,18 @@ function importLegacy(legacyDir) {
|
||||
imported++;
|
||||
}
|
||||
|
||||
if (imported) {
|
||||
if (imported || nextShelves.length !== shelves.length || nextTags.length !== tags.length) {
|
||||
const nextItems = [...items, ...importedItems];
|
||||
commit(nextItems, true);
|
||||
commit(nextItems, true, nextShelves, nextTags);
|
||||
}
|
||||
return { imported };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init, getRoot, filesDir, allocFilePath, sanitize,
|
||||
list, get, findBySource, add, update, remove, attachFile,
|
||||
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy, setChangeListener
|
||||
list, get, findBySource, listShelves, listTags,
|
||||
add, importLocal, update, remove, attachFile, addShelf, updateShelf, removeShelf,
|
||||
addTag, updateTag, removeTag,
|
||||
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
|
||||
ensureCoverCached, setGeneratedCover, setChangeListener
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user