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:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+765 -45
View File
@@ -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 增加 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 = [];
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
};