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
};
+35 -5
View File
@@ -19,16 +19,38 @@ function getFilePath() {
function load() {
if (cache) return cache;
try {
cache = JSON.parse(fs.readFileSync(getFilePath(), 'utf8')) || {};
const file = getFilePath();
const backup = `${file}.bak`;
if (!fs.existsSync(file) && fs.existsSync(backup)) fs.renameSync(backup, file);
cache = JSON.parse(fs.readFileSync(file, 'utf8')) || {};
} catch (e) { cache = {}; }
return cache;
}
function save() {
const dest = getFilePath();
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.mkdirSync(path.dirname(getFilePath()), { recursive: true });
fs.writeFileSync(getFilePath(), JSON.stringify(cache, null, 2), 'utf8');
} catch (e) { /* ignore */ }
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
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 (e) { /* 保留备份不影响提交 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (e) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollbackError) { /* 下次加载时会恢复 */ }
throw e;
}
}
function get(key, def) {
@@ -38,8 +60,16 @@ function get(key, def) {
function set(key, value) {
load();
const previous = cache[key];
const existed = Object.prototype.hasOwnProperty.call(cache, key);
cache[key] = value;
save();
try {
save();
} catch (e) {
if (existed) cache[key] = previous;
else delete cache[key];
throw e;
}
}
module.exports = { init, get, set };
+17 -9
View File
@@ -1,6 +1,9 @@
const { fetchJson, clampPage } = require('./http');
const PAGE_SIZE = 30;
const CATALOG_START = '2000-01-01';
const SNAPSHOT_TTL = 5 * 60 * 1000;
let catalogSnapshot = null;
function toItem(r, server) {
const authors = String(r.authors || '').split(';').map((s) => s.trim()).filter(Boolean).slice(0, 3).join(', ');
@@ -21,7 +24,7 @@ async function fetchWindow(server, from, to, cursor, tries = 3) {
let last;
for (let i = 0; i < tries; i++) {
try {
const j = await fetchJson(`https://api.biorxiv.org/details/${server}/${from}/${to}/${cursor}`);
const j = await fetchJson(`https://api.biorxiv.org/details/${server}/${from}/${to}/${cursor}`, { retries: 0 });
const total = parseInt(j.messages && j.messages[0] && j.messages[0].total, 10) || 0;
return { total, collection: j.collection || [] };
} catch (e) {
@@ -41,14 +44,19 @@ module.exports = {
async list(page) {
page = clampPage(page);
const server = 'biorxiv';
const end = new Date();
const daysPerPage = 3;
const startIdx = (page - 1) * daysPerPage;
const from = new Date(end.getTime() - (startIdx + daysPerPage) * 864e5);
const to = new Date(end.getTime() - startIdx * 864e5);
const { collection } = await fetchWindow(server, dateStr(from), dateStr(to), 0);
const items = collection.slice(0, PAGE_SIZE).map((r) => toItem(r, server));
return { items, maxPage: 1000, page };
const to = dateStr(new Date());
if (!catalogSnapshot || catalogSnapshot.to !== to || catalogSnapshot.expiresAt <= Date.now()) {
const first = await fetchWindow(server, CATALOG_START, to, 0);
catalogSnapshot = { to, total: first.total, expiresAt: Date.now() + SNAPSHOT_TTL };
}
const total = catalogSnapshot.total;
const end = Math.max(0, total - (page - 1) * PAGE_SIZE);
const cursor = Math.max(0, end - PAGE_SIZE);
const count = end - cursor;
if (!count) return { items: [], maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page };
const { collection } = await fetchWindow(server, CATALOG_START, to, cursor);
const items = collection.slice(0, count).reverse().map((r) => toItem(r, server));
return { items, maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page };
},
async search() {
+10 -2
View File
@@ -76,9 +76,17 @@ module.exports = {
const j = await fetchJson(`https://doaj.org/api/v2/articles/${encodeURIComponent(postId)}`);
const b = j.bibjson || {};
const files = [];
const links = [];
for (const l of b.link || []) {
if (l.url) files.push({ name: /pdf/i.test(l.content_type || '') ? 'PDF 全文' : (l.content_type || '全文'), link: l.url, format: l.content_type || '' });
if (!l.url) continue;
const type = l.content_type || '';
if (/pdf/i.test(type) || /\.pdf(?:$|[?#])/i.test(l.url)) {
files.push({ name: 'PDF 全文', link: l.url, format: 'PDF' });
} else {
links.push({ name: '全文页', url: l.url });
}
}
return { files, links: [{ name: 'DOAJ 页', url: `https://doaj.org/article/${postId}` }] };
links.push({ name: 'DOAJ 页', url: `https://doaj.org/article/${postId}` });
return { files, links };
}
};
+15 -101
View File
@@ -1,4 +1,5 @@
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
const { fetch: undiciFetch, ProxyAgent } = require('undici');
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
// 持久化存储在 userData/settings.json 的 "proxy" 字段。
@@ -6,112 +7,25 @@ let proxyUrl = '';
let dispatcher = null;
function setProxy(url) {
proxyUrl = String(url || '').trim();
dispatcher = null;
if (!proxyUrl) return;
// Electron 下代理主要由 session.setProxy 接管;这里仍然构建 ProxyAgent
// 供 net.fetch 不可用时的 undici 回退路径使用。
try {
const { ProxyAgent } = require('undici');
dispatcher = new ProxyAgent({
uri: proxyUrl,
requestTls: { rejectUnauthorized: false }
});
} catch (e) {
console.warn('代理初始化失败:', e.message);
const nextUrl = String(url || '').trim();
if (nextUrl === proxyUrl) return;
let nextDispatcher = null;
if (nextUrl) {
const parsed = new URL(nextUrl);
if (!/^https?:$/.test(parsed.protocol)) throw new Error('代理地址仅支持 http:// 或 https://');
nextDispatcher = new ProxyAgent({ uri: nextUrl });
}
if (dispatcher) {
dispatcher.close().catch(() => {});
}
proxyUrl = nextUrl;
dispatcher = nextDispatcher;
}
function getProxy() { return proxyUrl; }
// --- Electron net.fetch 响应头非 ASCII 崩溃的兜底 ---
// 部分站点(如 Memory of the World)会把搜索关键词原样回写进 ETag 等响应头。
// 关键词含中文时,Electron 的 net.fetch 在其内部 emit 回调里抛出
// "Cannot convert argument to a ByteString"。该异常无法被 await 捕获:
// 主进程直接崩溃,且对应的 fetch Promise 永远不会 settle。
//
// 兜底策略:拦截这一类 uncaughtException(其余异常原样交还默认处理),
// 记录 origin 后让仍在挂起中的同源请求转为可捕获的失败,再用 undici 重试。
// 加宽限期是因为异常与请求之间无法直接关联,宽限期内正常返回的请求不受影响。
const HEADER_BUG_GRACE = 1200;
// origin -> 该源已知会触发响应头崩溃(后续请求直接走 undici)
const headerBugOrigins = new Set();
// 正在进行中的 net.fetch: origin -> Set<abortFn>
const pendingByOrigin = new Map();
function originOf(url) {
try { return new URL(url).origin; } catch (e) { return ''; }
}
function isHeaderByteStringError(e) {
return !!e && (e instanceof TypeError || e.name === 'TypeError') && /ByteString/i.test(e.message || '');
}
let rethrowing = false;
function onUncaught(e) {
if (isHeaderByteStringError(e)) {
// 只有在宽限期后仍未返回的请求,才判定为被该异常卡死
const victims = [];
for (const set of pendingByOrigin.values()) victims.push(...set);
setTimeout(() => { for (const fail of victims) fail(); }, HEADER_BUG_GRACE);
return;
}
if (rethrowing) return;
rethrowing = true;
process.removeListener('uncaughtException', onUncaught);
setImmediate(() => { throw e; });
}
process.on('uncaughtException', onUncaught);
function undiciFetch(url, options) {
if (dispatcher) return fetch(url, { ...options, dispatcher });
return fetch(url, options);
}
function netFetch(url, options) {
const { net } = require('electron');
const origin = originOf(url);
return new Promise((resolve, reject) => {
let settled = false;
let set = pendingByOrigin.get(origin);
if (!set) { set = new Set(); pendingByOrigin.set(origin, set); }
const fail = () => {
if (settled) return;
settled = true;
cleanup();
headerBugOrigins.add(origin);
const err = new Error('响应头包含非 ASCII 字符,Electron 网络栈无法处理');
err.code = 'HEADER_BYTESTRING';
reject(err);
};
function cleanup() {
set.delete(fail);
if (!set.size) pendingByOrigin.delete(origin);
}
set.add(fail);
net.fetch(url, options).then(
(r) => { if (settled) return; settled = true; cleanup(); resolve(r); },
(e) => { if (settled) return; settled = true; cleanup(); reject(e); }
);
});
}
async function fetchWithProxy(url, options = {}) {
// Electron 主进程优先用 net.fetch(走 Chromium 网络栈,由 session.setProxy 控制代理)
if (process.versions.electron) {
// 已知有问题的源直接走 undici,避免每次都重新触发一遍崩溃
if (headerBugOrigins.has(originOf(url))) return undiciFetch(url, options);
try {
return await netFetch(url, options);
} catch (e) {
if (e && e.code === 'HEADER_BYTESTRING') return undiciFetch(url, options);
throw e;
}
}
return undiciFetch(url, options);
function fetchWithProxy(url, options = {}) {
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options);
}
// 简易 cookie jar: Map<domain, Map<name, value>>
+5 -3
View File
@@ -76,7 +76,8 @@ module.exports = {
async list(page) {
page = clampPage(page);
const j = await fetchJson(`${BASE}/books?page=${page}`);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchJson(`${BASE}/books?offset=${offset}&limit=${PAGE_SIZE}`);
return pack(j, page);
},
@@ -84,11 +85,12 @@ module.exports = {
page = clampPage(page);
const kw = safeKeyword(keyword);
if (!kw) return { items: [], maxPage: 1, page };
const offset = (page - 1) * PAGE_SIZE;
// 标题与作者两路合并,按 _id 去重
const [byTitle, byAuthor] = await Promise.all([
fetchJson(`${BASE}/search/titles/${kw}?page=${page}`).catch(() => null),
fetchJson(`${BASE}/search/authors/${kw}?page=${page}`).catch(() => null)
fetchJson(`${BASE}/search/titles/${kw}?offset=${offset}&limit=${PAGE_SIZE}`).catch(() => null),
fetchJson(`${BASE}/search/authors/${kw}?offset=${offset}&limit=${PAGE_SIZE}`).catch(() => null)
]);
if (!byTitle && !byAuthor) throw new Error('搜索请求失败');
+19 -4
View File
@@ -1,6 +1,7 @@
const { fetchJson, clampPage } = require('./http');
const { fetchJson, fetchText, clampPage } = require('./http');
const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
const OA_DATA = 'https://pmc-oa-opendata.s3.amazonaws.com';
const PAGE_SIZE = 20;
function toItem(r) {
@@ -35,13 +36,26 @@ async function runList(term, page) {
return { items, maxPage: Math.max(1, Math.ceil(count / PAGE_SIZE)), page };
}
async function resolvePdf(postId) {
const pmcid = `PMC${String(postId).replace(/^PMC/i, '')}`;
const listing = await fetchText(`${OA_DATA}/?list-type=2&prefix=${encodeURIComponent(`${pmcid}.`)}&delimiter=%2F`);
const versions = Array.from(listing.matchAll(new RegExp(`<Prefix>${pmcid}\\.(\\d+)/</Prefix>`, 'g')))
.map((m) => parseInt(m[1], 10))
.filter(Number.isFinite);
if (!versions.length) throw new Error('该文献不在 PMC 可下载数据集中');
const version = Math.max(...versions);
const meta = await fetchJson(`${OA_DATA}/metadata/${pmcid}.${version}.json`);
if (!meta.pdf_url) throw new Error('该文献未提供 PDF');
return meta.pdf_url.replace('s3://pmc-oa-opendata/', `${OA_DATA}/`);
}
module.exports = {
id: 'pmc',
name: 'PMC 生物医学',
supportsSearch: true,
list(page) { return runList('open access[filter]', page); },
search(keyword, page) { return runList(`${keyword} AND open access[filter]`, page); },
list(page) { return runList('open access[filter] AND has_pdf[filter]', page); },
search(keyword, page) { return runList(`${keyword} AND open access[filter] AND has_pdf[filter]`, page); },
async detail(postId) {
const result = await esummary([postId]);
@@ -65,8 +79,9 @@ module.exports = {
async download(postId) {
const page = `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`;
const link = await resolvePdf(postId);
return {
files: [{ name: `PMC${postId}.pdf`, link: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/pdf/`, format: 'PDF' }],
files: [{ name: `PMC${postId}.pdf`, link, format: 'PDF' }],
links: [{ name: 'PMC 全文页', url: page }]
};
}
+98
View File
@@ -0,0 +1,98 @@
const fs = require('fs');
const path = require('path');
let filePath = null;
let safeStorage = null;
let sessionKey = '';
let cachedKey = null;
let keyRevision = 0;
function init(userDataDir, storage) {
filePath = path.join(userDataDir, 'semantic-scholar-key.bin');
safeStorage = storage;
sessionKey = '';
cachedKey = null;
keyRevision++;
}
function encryptionAvailable() {
return !!safeStorage && safeStorage.isEncryptionAvailable();
}
function read() {
if (sessionKey) return sessionKey;
if (cachedKey !== null) return cachedKey;
if (!filePath || !encryptionAvailable()) return '';
try {
const backup = `${filePath}.bak`;
if (!fs.existsSync(filePath) && fs.existsSync(backup)) fs.renameSync(backup, filePath);
cachedKey = safeStorage.decryptString(fs.readFileSync(filePath));
return cachedKey;
} catch (e) {
cachedKey = '';
return '';
}
}
function write(key) {
const value = String(key || '').trim();
if (!value) {
clear();
return { persistent: encryptionAvailable() };
}
if (!encryptionAvailable()) {
sessionKey = value;
cachedKey = value;
keyRevision++;
return { persistent: false };
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temp = `${filePath}.tmp`;
const backup = `${filePath}.bak`;
let backedUp = false;
try {
fs.writeFileSync(temp, safeStorage.encryptString(value));
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(filePath)) {
fs.renameSync(filePath, backup);
backedUp = true;
}
fs.renameSync(temp, filePath);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* ignore */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (e) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(filePath) && fs.existsSync(backup)) fs.renameSync(backup, filePath);
} catch (rollbackError) { /* 下次读取时恢复 */ }
throw e;
}
sessionKey = '';
cachedKey = value;
keyRevision++;
return { persistent: true };
}
function clear() {
sessionKey = '';
cachedKey = '';
keyRevision++;
if (!filePath) return;
for (const suffix of ['', '.bak', '.tmp']) {
try { fs.unlinkSync(`${filePath}${suffix}`); } catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
}
function status() {
return {
configured: !!read(),
persistent: encryptionAvailable()
};
}
function revision() { return keyRevision; }
module.exports = { init, read, write, clear, status, revision };
+131 -15
View File
@@ -1,12 +1,124 @@
const { fetchJson, clampPage } = require('./http');
const { fetchRaw, clampPage } = require('./http');
const apiKey = require('./semantic-key');
const BASE = 'https://api.semanticscholar.org/graph/v1';
const PAGE_SIZE = 25;
const FIELDS = 'title,authors,year,abstract,openAccessPdf,externalIds,url,venue';
const SEARCH_FIELDS = 'title,authors,year,url,venue';
const DETAIL_FIELDS = 'title,authors,year,abstract,openAccessPdf,externalIds,url,venue';
const REQUEST_INTERVAL = 1100;
const SEARCH_TTL = 10 * 60 * 1000;
const DETAIL_TTL = 24 * 60 * 60 * 1000;
const CACHE_LIMIT = 100;
// 该 API 对匿名调用限流很严。只重试一次,避免在聚合搜索里长时间阻塞整组结果。
function fetchWithRetry(url) {
return fetchJson(url, { retryDelay: 1500 });
let queue = Promise.resolve();
let nextRequestAt = 0;
let cooldownUntil = 0;
let consecutive429 = 0;
let lastKeyRevision = -1;
const cache = new Map();
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function syncKeyState() {
const revision = apiKey.revision();
if (revision === lastKeyRevision) return;
lastKeyRevision = revision;
cooldownUntil = 0;
consecutive429 = 0;
cache.clear();
}
function enqueue(fn) {
const result = queue.then(fn);
queue = result.catch(() => {});
return result;
}
async function waitForSlot() {
const delay = Math.max(0, nextRequestAt - Date.now());
if (delay) await sleep(delay);
nextRequestAt = Date.now() + REQUEST_INTERVAL;
}
function retryDelay(res) {
const value = res.headers.get('retry-after');
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, Math.min(seconds * 1000, 5 * 60 * 1000));
const at = Date.parse(value);
if (Number.isFinite(at)) return Math.max(0, Math.min(at - Date.now(), 5 * 60 * 1000));
}
return 2000 + Math.floor(Math.random() * 2001);
}
async function requestOnce(url) {
await waitForSlot();
const key = apiKey.read();
const headers = key ? { 'x-api-key': key } : {};
const res = await fetchRaw(url, { headers, timeout: 15000 });
if (res.ok) {
const text = await res.text();
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Semantic Scholar 返回内容不是有效 JSON');
}
}
const delay = res.status === 429 ? retryDelay(res) : 0;
try { await res.body?.cancel(); } catch (e) { /* ignore */ }
const err = new Error(res.status === 429
? 'Semantic Scholar 请求过于频繁(429'
: `Semantic Scholar 请求失败(HTTP ${res.status}`);
err.status = res.status;
err.retryDelay = delay;
throw err;
}
async function requestWithCooldown(url) {
syncKeyState();
if (Date.now() < cooldownUntil) {
const seconds = Math.max(1, Math.ceil((cooldownUntil - Date.now()) / 1000));
throw new Error(`Semantic Scholar 正在限流冷却,请约 ${seconds} 秒后重试`);
}
try {
const data = await requestOnce(url);
consecutive429 = 0;
return data;
} catch (e) {
if (e.status !== 429) throw e;
await sleep(e.retryDelay);
try {
const data = await requestOnce(url);
consecutive429 = 0;
return data;
} catch (retryError) {
if (retryError.status !== 429) throw retryError;
consecutive429++;
const seconds = Math.min(300, 30 * (2 ** (consecutive429 - 1)));
cooldownUntil = Date.now() + seconds * 1000;
throw new Error(`Semantic Scholar 持续限流,已暂停请求 ${seconds}`);
}
}
}
function cachedRequest(url, ttl) {
syncKeyState();
const now = Date.now();
const existing = cache.get(url);
if (existing && existing.expiresAt > now) {
cache.delete(url);
cache.set(url, existing);
return existing.promise;
}
if (existing) cache.delete(url);
const promise = enqueue(() => requestWithCooldown(url));
cache.set(url, { promise, expiresAt: now + ttl });
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
promise.catch(() => {
const current = cache.get(url);
if (current && current.promise === promise) cache.delete(url);
});
return promise;
}
function toItem(p) {
@@ -20,6 +132,10 @@ function toItem(p) {
};
}
function paperMetadata(postId) {
return cachedRequest(`${BASE}/paper/${encodeURIComponent(postId)}?fields=${DETAIL_FIELDS}`, DETAIL_TTL);
}
module.exports = {
id: 'semanticscholar',
name: 'Semantic Scholar',
@@ -27,22 +143,22 @@ module.exports = {
async list(page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent('a')}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page };
return { items: [], maxPage: 1, page, note: '请输入关键词搜索 Semantic Scholar' };
},
async search(keyword, page) {
page = clampPage(page);
page = Math.min(clampPage(page), 40);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent(keyword)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page };
const query = String(keyword || '').trim();
if (!query) return { items: [], maxPage: 1, page: 1 };
const url = `${BASE}/paper/search?query=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${SEARCH_FIELDS}`;
const j = await cachedRequest(url, SEARCH_TTL);
const maxPage = Math.max(1, Math.ceil(Math.min(j.total || 0, 1000) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage, page };
},
async detail(postId) {
const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=${FIELDS}`);
const p = await paperMetadata(postId);
return {
postId,
title: p.title || '(无标题)',
@@ -60,7 +176,7 @@ module.exports = {
},
async download(postId) {
const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=title,openAccessPdf,url,externalIds`);
const p = await paperMetadata(postId);
const files = [];
if (p.openAccessPdf && p.openAccessPdf.url) {
files.push({ name: `${String(p.title || postId).replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.pdf`, link: p.openAccessPdf.url, format: 'PDF' });
+111 -51
View File
@@ -1,50 +1,100 @@
const { fetchText, clampPage, decodeEntities } = require('./http');
const { fetchText, fetchJson, clampPage, decodeEntities, stripTags } = require('./http');
const BASE = 'https://standardebooks.org';
const PAGE_SIZE = 24;
const DETAIL_TTL = 5 * 60 * 1000;
const detailCache = new Map();
async function fetchOpds(path) {
return fetchText(`${BASE}${path}`, { headers: { 'Accept': 'application/atom+xml, text/xml, */*' } });
function absolute(url) {
return url ? new URL(url, BASE).toString() : '';
}
function parseEntries(xml) {
const entries = [];
const re = /<entry>([\s\S]*?)<\/entry>/g;
function slugFromUrl(url) {
return String(url || '').replace(/^https?:\/\/standardebooks\.org\/ebooks\//, '').replace(/^\/?ebooks\//, '').replace(/^\/+|\/+$/g, '');
}
function postId(slug) { return encodeURIComponent(slug); }
function parseCatalog(html) {
const items = [];
const re = /<li\s+typeof="schema:Book"\s+about="([^"]+)"([\s\S]*?)(?=<li\s+typeof="schema:Book"|<\/ol>)/g;
let m;
while ((m = re.exec(xml))) {
const e = m[1];
const title = decodeEntities((e.match(/<title>([\s\S]*?)<\/title>/) || [])[1] || '').trim();
const id = decodeEntities((e.match(/<id>([\s\S]*?)<\/id>/) || [])[1] || '').trim();
const author = decodeEntities((e.match(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/) || [])[1] || '').trim();
const summary = decodeEntities((e.match(/<summary>([\s\S]*?)<\/summary>/) || [])[1] || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
let epub = '', cover = '', pageUrl = '';
const lre = /<link[^>]*\/>/g;
let l;
while ((l = lre.exec(e))) {
const tag = l[0];
const href = (tag.match(/href="([^"]+)"/) || [])[1] || '';
const rel = (tag.match(/rel="([^"]+)"/) || [])[1] || '';
if (/epub/i.test(tag) && !epub) epub = href;
else if (/image/.test(tag) && !cover) cover = href;
else if (rel === 'alternate' && !pageUrl) pageUrl = href;
}
const slug = id.replace(/^urn:uuid:|^https?:\/\/standardebooks\.org\/ebooks\//, '').replace(/\//g, '_') || title;
entries.push({ slug, title, author, summary, epub, cover, pageUrl });
while ((m = re.exec(html))) {
const slug = slugFromUrl(m[1]);
const block = m[2];
const title = stripTags((block.match(/property="schema:name">([\s\S]*?)<\/span>/) || [])[1] || '');
const author = stripTags((block.match(/class="author"[^>]*>([\s\S]*?)<\/p>/) || [])[1] || '');
const cover = (block.match(/<img[^>]*property="schema:image"[^>]*src="([^"]+)"/) || [])[1]
|| (block.match(/<img[^>]*src="([^"]+)"[^>]*property="schema:image"/) || [])[1] || '';
if (!slug || !title) continue;
items.push({
postId: postId(slug),
title,
cover: absolute(decodeEntities(cover)),
date: '',
url: `${BASE}/ebooks/${slug}`,
subtitle: author
});
}
return entries;
return items;
}
function toItem(e) {
function catalogMaxPage(html, page) {
const pages = Array.from(html.matchAll(/[?&]page=(\d+)/g)).map((m) => parseInt(m[1], 10));
return Math.max(page, ...pages.filter(Number.isFinite));
}
function publicationToItem(p) {
const metadata = p.metadata || {};
const slug = slugFromUrl(metadata.identifier);
const authors = Array.isArray(metadata.author) ? metadata.author : (metadata.author ? [metadata.author] : []);
const image = (p.images || []).find((x) => x && x.href);
return {
postId: encodeURIComponent(e.slug),
title: e.title,
cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '',
date: '',
url: e.pageUrl ? (e.pageUrl.startsWith('http') ? e.pageUrl : BASE + e.pageUrl) : '',
subtitle: e.author
postId: postId(slug),
title: metadata.title || '(无标题)',
cover: image ? absolute(image.href) : '',
date: String(metadata.published || '').slice(0, 10),
url: `${BASE}/ebooks/${slug}`,
subtitle: authors.map((a) => a.name || '').filter(Boolean).join(', ')
};
}
function parseDetail(html, slug) {
const title = stripTags((html.match(/<h1[^>]*property="schema:name"[^>]*>([\s\S]*?)<\/h1>/) || [])[1] || '');
const authorBlock = (html.match(/<a[^>]*property="schema:author"[^>]*>([\s\S]*?)<\/a>/) || [])[1] || '';
const author = stripTags((authorBlock.match(/property="schema:name"[^>]*>([\s\S]*?)<\/span>/) || [])[1] || authorBlock);
const brief = decodeEntities((html.match(/<meta[^>]*property="schema:description"[^>]*content="([^"]*)"/) || [])[1] || '');
const cover = (html.match(/<meta[^>]*property="schema:image"[^>]*content="([^"]+)"/) || [])[1] || '';
const date = (html.match(/<meta[^>]*property="schema:datePublished"[^>]*content="([^"]+)"/) || [])[1] || '';
const epub = (html.match(/<a[^>]*property="schema:contentUrl"[^>]*href="([^"]+)"[^>]*class="epub"/) || [])[1] || '';
return { slug, title, author, brief, cover: absolute(cover), date, epub: absolute(epub) };
}
function validateSlug(post) {
const slug = decodeURIComponent(post);
if (!/^[a-z0-9-]+(?:\/[a-z0-9-]+)+$/i.test(slug)) throw new Error('无效的 Standard Ebooks ID');
return slug;
}
async function loadDetail(post) {
const slug = validateSlug(post);
const cached = detailCache.get(slug);
if (cached && cached.expiresAt > Date.now()) return cached.promise;
const promise = fetchText(`${BASE}/ebooks/${slug}`).then((html) => {
const detail = parseDetail(html, slug);
if (!detail.title) throw new Error('未找到该图书');
return detail;
});
detailCache.set(slug, { promise, expiresAt: Date.now() + DETAIL_TTL });
while (detailCache.size > 50) detailCache.delete(detailCache.keys().next().value);
try {
return await promise;
} catch (e) {
detailCache.delete(slug);
throw e;
}
}
module.exports = {
id: 'standardebooks',
name: 'Standard Ebooks',
@@ -52,40 +102,50 @@ module.exports = {
async list(page) {
page = clampPage(page);
const xml = await fetchOpds(`/feeds/opds/all?page=${page}`);
return { items: parseEntries(xml).map(toItem), maxPage: 40, page };
const html = await fetchText(`${BASE}/ebooks?page=${page}&per-page=${PAGE_SIZE}&view=list`);
return { items: parseCatalog(html), maxPage: catalogMaxPage(html, page), page };
},
async search(keyword, page) {
page = clampPage(page);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(keyword)}&page=${page}`);
return { items: parseEntries(xml).map(toItem), maxPage: 40, page };
const j = await fetchJson(`${BASE}/feeds/opds/all?query=${encodeURIComponent(keyword)}&per-page=${PAGE_SIZE}&page=${page}`, {
headers: { 'Accept': 'application/opds+json' }
});
const publications = j.publications || [];
return {
items: publications.map(publicationToItem).filter((x) => decodeURIComponent(x.postId)),
maxPage: publications.length === PAGE_SIZE ? page + 1 : page,
page
};
},
async detail(postId) {
const slug = decodeURIComponent(postId);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`);
const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0];
if (!e) throw new Error('未找到该图书');
const e = await loadDetail(postId);
return {
postId,
title: e.title,
cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '',
cover: e.cover,
authors: e.author ? [e.author] : [],
date: '',
date: e.date,
tags: [],
brief: e.summary,
url: e.pageUrl,
links: e.pageUrl ? [{ name: 'Standard Ebooks 页', url: e.pageUrl }] : []
brief: e.brief,
url: `${BASE}/ebooks/${e.slug}`,
links: [{ name: 'Standard Ebooks 页', url: `${BASE}/ebooks/${e.slug}` }]
};
},
async download(postId) {
const slug = decodeURIComponent(postId);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`);
const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0];
const e = await loadDetail(postId);
if (!e || !e.epub) throw new Error('未找到 EPUB 下载');
const url = e.epub.startsWith('http') ? e.epub : BASE + e.epub;
return { files: [{ name: `${e.title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.epub`, link: url, format: 'EPUB' }], links: [] };
const url = new URL(e.epub);
url.searchParams.set('source', 'download');
return {
files: [{
name: `${e.title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.epub`,
link: url.toString(),
format: 'EPUB'
}],
links: []
};
}
};
+122 -2
View File
@@ -84,19 +84,139 @@ $('zlibLogoutBtn').onclick = async () => {
refreshZlibStatus();
async function refreshLibraryDir() {
const r = await window.api.library.getDir();
if (r.ok && r.data) $('libDirPath').textContent = r.data.dir + (r.data.isDefault ? '(默认)' : '');
}
$('libDirPickBtn').onclick = async () => {
const pick = await window.api.library.pickDir();
if (!pick.ok || !pick.data) return;
const dest = pick.data;
const cur = await window.api.library.getDir();
if (cur.ok && cur.data && cur.data.dir === dest) return;
// 让用户决定旧目录里已有的书怎么处理
let migrate = false;
const choice = await openModal('切换书库目录', `
<p>新目录:</p>
<div class="settings-path" style="margin:6px 0 12px;">${escapeHtml(dest)}</div>
<label style="display:block;margin-bottom:6px;">
<input type="radio" name="migMode" value="migrate" checked /> 迁移:把现有书库内容移动到新目录
</label>
<label style="display:block;">
<input type="radio" name="migMode" value="switch" /> 直接切换:旧目录原样保留,新目录重新扫描
</label>
`, () => {
const sel = document.querySelector('input[name="migMode"]:checked');
return { migrate: sel && sel.value === 'migrate' };
});
if (!choice) return;
migrate = choice.migrate;
const res = await window.api.library.setDir(dest, migrate);
if (!res.ok) { await confirmModal('切换失败', res.error || '未知错误'); return; }
await refreshLibraryDir();
if (window.Library) window.Library.markDirty();
};
$('libDirOpenBtn').onclick = async () => {
const r = await window.api.library.getDir();
if (r.ok && r.data) window.api.openPath(r.data.dir);
};
async function refreshAskSave() {
const r = await window.api.settings.get('askSavePath', false);
$('askSaveChk').checked = !!(r.ok && r.data);
}
$('askSaveChk').onchange = () => window.api.settings.set('askSavePath', $('askSaveChk').checked);
refreshLibraryDir();
refreshAskSave();
async function refreshProxy() {
const r = await window.api.proxy.get();
if (r.ok) $('proxyInput').value = r.data || '';
}
$('proxySaveBtn').onclick = async () => {
await window.api.proxy.set($('proxyInput').value.trim());
$('proxySaveBtn').textContent = '已保存 ✓';
const r = await window.api.proxy.set($('proxyInput').value.trim());
$('proxySaveBtn').textContent = r.ok ? '已保存 ✓' : '保存失败';
$('proxySaveBtn').title = r.ok ? '' : (r.error || '代理地址无效');
setTimeout(() => { $('proxySaveBtn').textContent = '保存'; }, 1500);
};
refreshProxy();
async function refreshSemanticKeyStatus() {
const r = await window.api.semanticScholar.keyStatus();
const status = r.ok && r.data ? r.data : { configured: false, persistent: false };
$('semanticKeyStatus').textContent = status.configured
? (status.persistent ? '已配置(由系统安全存储加密)' : '已配置(仅本次运行)')
: '未配置(匿名请求容易被限流)';
$('semanticKeyClearBtn').classList.toggle('hidden', !status.configured);
}
$('semanticKeySaveBtn').onclick = async () => {
const key = $('semanticKeyInput').value.trim();
if (!key) {
$('semanticKeySaveBtn').textContent = '请输入 Key';
setTimeout(() => { $('semanticKeySaveBtn').textContent = '保存'; }, 1500);
return;
}
const r = await window.api.semanticScholar.setKey(key);
$('semanticKeyInput').value = '';
$('semanticKeySaveBtn').textContent = r.ok ? '已保存 ✓' : '保存失败';
$('semanticKeySaveBtn').title = r.ok ? '' : (r.error || '保存失败');
await refreshSemanticKeyStatus();
setTimeout(() => { $('semanticKeySaveBtn').textContent = '保存'; }, 1500);
};
$('semanticKeyClearBtn').onclick = async () => {
const ok = await confirmModal('清除 API Key', '确定清除 Semantic Scholar API Key 吗?');
if (!ok) return;
await window.api.semanticScholar.clearKey();
await refreshSemanticKeyStatus();
};
refreshSemanticKeyStatus();
async function runUpdateCheck(silent) {
const statusEl = $('updateStatus');
const btn = $('checkUpdateBtn');
if (!silent) {
btn.disabled = true;
statusEl.textContent = '正在检查...';
}
const res = await window.api.checkUpdate();
if (!silent) btn.disabled = false;
if (!res.ok) {
if (!silent) statusEl.textContent = '检查失败:' + res.error;
return;
}
const { latest, hasUpdate, url } = res.data;
if (hasUpdate) {
statusEl.textContent = `发现新版本 ${latest}`;
const ok = await openModal(
'发现新版本',
`<p>检测到新版本 <b>${escapeHtml(latest)}</b>,是否前往 GitHub Releases 下载?</p>`
);
if (ok) window.api.openExternal(url);
} else if (!silent) {
statusEl.textContent = '已是最新版本';
}
}
window.api.getVersion().then((r) => {
if (r && r.ok) $('appVersion').textContent = 'v' + r.data;
});
const autoCheckEl = $('autoCheckUpdate');
window.api.settings.get('autoCheckUpdate', false).then((r) => {
autoCheckEl.checked = !!(r.ok && r.data);
if (autoCheckEl.checked) runUpdateCheck(true);
});
autoCheckEl.onchange = () => window.api.settings.set('autoCheckUpdate', autoCheckEl.checked);
$('checkUpdateBtn').onclick = () => runUpdateCheck(false);
switchTab('library');
+42 -2
View File
@@ -30,6 +30,7 @@
<div class="toolbar">
<span id="libStatus" class="status-bar"></span>
<div class="spacer"></div>
<button id="rescanBtn" class="tb-btn ghost">重新扫描</button>
<button id="addLocalBtn" class="tb-btn">+ 添加本地文件</button>
</div>
<div id="libGrid" class="grid"></div>
@@ -80,6 +81,24 @@
<div id="sourceList" class="source-list"></div>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">书库目录</div>
<div class="settings-item-desc">元数据、下载文件与封面都存放在此目录;启动时自动扫描 files 子目录导入新书</div>
<div class="settings-path" id="libDirPath">-</div>
</div>
<button id="libDirPickBtn" class="tb-btn">更改</button>
<button id="libDirOpenBtn" class="tb-btn ghost">打开</button>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">下载前询问保存位置</div>
<div class="settings-item-desc">关闭时直接存入书库目录并自动入库;开启则每次弹出另存为对话框</div>
</div>
<label class="switch"><input type="checkbox" id="askSaveChk" /></label>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
@@ -103,6 +122,17 @@
<button id="proxySaveBtn" class="tb-btn">保存</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">Semantic Scholar API Key</div>
<div class="settings-item-desc" id="semanticKeyStatus">未配置(匿名请求容易被限流)</div>
</div>
<input id="semanticKeyInput" class="settings-input" type="password" placeholder="可选 API Key" autocomplete="off" />
<button id="semanticKeySaveBtn" class="tb-btn">保存</button>
<button id="semanticKeyClearBtn" class="tb-btn ghost hidden">清除</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
@@ -116,9 +146,19 @@
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">关于</div>
<div class="settings-item-desc">PeopleLib <span id="appVersion">-</span> · 开放获取文献与公版图书客户端</div>
<div class="settings-item-label">检查更新</div>
<div class="settings-item-desc">当前版本 <span id="appVersion">-</span> · <span id="updateStatus">从 GitHub 获取最新版本</span></div>
</div>
<button id="checkUpdateBtn" class="tb-btn">检查更新</button>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">启动时自动检查更新</div>
<div class="settings-item-desc">发现新版本后可前往 GitHub Releases 下载二进制文件</div>
</div>
<label class="switch">
<input type="checkbox" id="autoCheckUpdate" />
</label>
</div>
</div>
</div>
+47 -12
View File
@@ -93,6 +93,11 @@ body {
.browse-head .toolbar { margin-bottom: 0; }
.browse-head .status-bar { margin: 0; min-height: 0; }
.browse-head .status-bar:not(:empty) { margin-top: 8px; }
#browseGridView {
min-height: calc(100vh - 84px);
display: flex;
flex-direction: column;
}
.source-select {
height: 30px; padding: 0 12px;
@@ -157,25 +162,43 @@ body {
/* 分页 */
.pager {
position: sticky; bottom: -20px; z-index: 6;
margin: 16px -20px -20px; padding: 12px 20px 16px;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
position: sticky;
bottom: -20px;
margin: auto -20px -20px;
padding: 14px 20px 20px;
background: linear-gradient(to top, var(--bg) 62%, transparent);
display: flex; align-items: center; justify-content: center; gap: 12px;
z-index: 5;
}
.page-btn {
height: 28px; padding: 0 14px;
background: var(--bg-soft); color: var(--text);
border: 1px solid var(--line); border-radius: 8px; font-size: 12px; cursor: pointer;
padding: 8px 20px;
background: var(--bg-soft);
border: 1px solid var(--line);
color: var(--text);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.page-btn:hover:not(:disabled) { border-color: var(--accent); }
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.page-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-bright); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-info { color: var(--text-dim); font-size: 13px; }
.page-jump { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12px; }
.page-jump { display: flex; align-items: center; gap: 8px; color: var(--text-dim); font-size: 13px; }
.jump-input {
width: 60px; height: 28px; text-align: center;
background: rgba(255,255,255,0.06); border: 1px solid var(--line); border-radius: 8px;
color: var(--text); font-size: 12px; outline: none;
width: 64px;
padding: 7px 8px;
background: var(--bg-soft);
border: 1px solid var(--line);
color: var(--text);
border-radius: 8px;
font-size: 13px;
text-align: center;
}
.jump-input:focus { outline: none; border-color: var(--accent); }
.jump-input::-webkit-outer-spin-button,
.jump-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* 详情 */
.back-btn {
@@ -258,9 +281,21 @@ body {
.settings-item-block { flex-direction: column; align-items: stretch; }
.settings-item-label { font-size: 14px; font-weight: 600; }
.settings-item-desc { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
.settings-input {
width: 220px; padding: 6px 10px;
background: #222; border: 1px solid #444; color: #eee; border-radius: 4px;
}
.settings-input:focus { outline: none; border-color: var(--accent); }
.source-list { display: flex; flex-direction: column; gap: 2px; padding: 10px 0 14px; }
.source-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; cursor: pointer; }
.source-row input { accent-color: var(--accent); }
.settings-path {
margin-top: 6px; padding: 6px 10px;
background: rgba(255,255,255,0.05); border: 1px solid var(--line); border-radius: 6px;
font-family: Consolas, monospace; font-size: 11px; color: var(--text-dim);
word-break: break-all;
}
.switch input { width: 38px; height: 20px; accent-color: var(--accent); cursor: pointer; }
/* 弹窗 */
.modal {
+76 -35
View File
@@ -12,6 +12,8 @@ const Browse = (() => {
scrollY: 0,
sourceList: [],
aggToken: 0,
gridToken: 0,
detailToken: 0,
activeSourceId: null,
currentPostId: null,
currentDetail: null
@@ -127,6 +129,7 @@ const Browse = (() => {
}
async function loadGrid() {
const token = ++state.gridToken;
state.aggToken++;
showGrid();
statusBar.textContent = '加载中...';
@@ -136,14 +139,19 @@ const Browse = (() => {
if (isAgg()) return loadAggregate();
const res = state.mode === 'search'
? await window.api.sources.search(state.sourceId, state.keyword, state.page)
: await window.api.sources.browse(state.sourceId, state.page);
const sourceId = state.sourceId;
const mode = state.mode;
const keyword = state.keyword;
const page = state.page;
const res = mode === 'search'
? await window.api.sources.search(sourceId, keyword, page)
: await window.api.sources.browse(sourceId, page);
if (token !== state.gridToken) return;
grid.className = 'grid';
if (!res.ok) {
const isAuth = state.sourceId === 'zlib' && /登录|登录|AUTH/i.test(res.error || '');
const isAuth = sourceId === 'zlib' && /登录|AUTH/i.test(res.error || '');
statusBar.innerHTML = `加载失败:${escapeHtml(res.error)} <button class="retry-btn" id="gridRetry">重试</button>`;
if (isAuth) {
grid.innerHTML = '<div class="empty">Z-Library 需要登录,请到"设置"页配置账号</div>';
@@ -158,23 +166,23 @@ const Browse = (() => {
state.maxPage = maxPage || 1;
if (!items.length) {
grid.innerHTML = '<div class="empty">未找到相关结果</div>';
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}` : '';
grid.innerHTML = `<div class="empty">${escapeHtml(res.data.note || '未找到相关结果')}</div>`;
statusBar.textContent = mode === 'search' ? `搜索:${keyword}` : '';
return;
}
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}(第 ${state.page} 页)` : '';
statusBar.textContent = mode === 'search' ? `搜索:${keyword}(第 ${page} 页)` : '';
grid.innerHTML = items.map(cardHtml).join('');
bindCards(grid, state.sourceId);
bindCards(grid, sourceId);
$('pageInfo').textContent = `${state.page} / ${state.maxPage}`;
$('prevBtn').disabled = state.page <= 1;
$('nextBtn').disabled = state.page >= state.maxPage;
$('pageInfo').textContent = `${page} / ${state.maxPage}`;
$('prevBtn').disabled = page <= 1;
$('nextBtn').disabled = page >= state.maxPage;
const jump = $('jumpInput');
jump.max = state.maxPage;
jump.value = '';
jump.placeholder = state.page;
jump.placeholder = page;
pager.classList.remove('hidden');
}
@@ -280,21 +288,25 @@ const Browse = (() => {
}
function showGrid() {
state.detailToken++;
detailView.classList.add('hidden');
gridView.classList.remove('hidden');
if (state.scrollY) mainEl.scrollTop = state.scrollY;
}
async function openDetail(postId, sourceId) {
const token = ++state.detailToken;
state.scrollY = mainEl.scrollTop;
state.currentPostId = postId;
state.activeSourceId = sourceId || state.sourceId;
const activeSourceId = sourceId || state.sourceId;
state.activeSourceId = activeSourceId;
gridView.classList.add('hidden');
detailView.classList.remove('hidden');
mainEl.scrollTop = 0;
detailContent.innerHTML = '<div class="dl-loading">加载中...</div>';
const res = await window.api.sources.detail(state.activeSourceId, postId);
const res = await window.api.sources.detail(activeSourceId, postId);
if (token !== state.detailToken) return;
if (!res.ok) {
detailContent.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="detailRetry">重试</button></div>`;
$('detailRetry').onclick = () => openDetail(postId, state.activeSourceId);
@@ -302,7 +314,7 @@ const Browse = (() => {
}
state.currentDetail = res.data;
renderDetail(res.data);
loadDownload(postId);
loadDownload(postId, activeSourceId, token);
refreshAddButton();
}
@@ -358,16 +370,7 @@ const Browse = (() => {
async function addToLibrary() {
const d = state.currentDetail;
if (!d) return;
const res = await window.api.library.add({
title: d.title,
authors: d.authors || [],
cover: d.cover,
date: d.date || '',
brief: d.brief || '',
url: d.url || '',
sourceId: state.activeSourceId,
sourcePostId: state.currentPostId
});
const res = await window.api.library.add(entryMeta());
if (res.ok) {
const btn = $('addLibBtn');
btn.textContent = '已加入书库 ✓';
@@ -377,15 +380,15 @@ const Browse = (() => {
}
}
async function loadDownload(postId) {
async function loadDownload(postId, sourceId = state.activeSourceId, token = state.detailToken) {
const box = $('downloadBox');
if (!box) return;
box.innerHTML = '<div class="dl-loading">下载信息获取中...</div>';
const res = await window.api.sources.download(state.activeSourceId, postId);
if (!box.isConnected) return;
const res = await window.api.sources.download(sourceId, postId);
if (!box.isConnected || token !== state.detailToken) return;
if (!res.ok) {
box.innerHTML = `<div class="dl-error">获取失败:${escapeHtml(res.error)}<button class="retry-btn" id="dlRetry">重试</button></div>`;
$('dlRetry').onclick = () => loadDownload(postId);
$('dlRetry').onclick = () => loadDownload(postId, sourceId, token);
return;
}
const d = res.data;
@@ -409,24 +412,62 @@ const Browse = (() => {
box.querySelectorAll('[data-open]').forEach((a) => { a.onclick = (e) => { e.preventDefault(); window.api.openExternal(a.dataset.open); }; });
}
// 当前条目的元数据,供下载时自动建库用
function entryMeta() {
const d = state.currentDetail || {};
return {
title: d.title || '未命名',
authors: d.authors || [],
cover: d.cover || '',
date: d.date || '',
brief: d.brief || '',
url: d.url || '',
sourceId: state.activeSourceId,
sourcePostId: state.currentPostId
};
}
async function downloadFile(btn, url) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '下载中...';
const lib = await window.api.library.findBySource(state.activeSourceId, state.currentPostId);
const entryId = (lib.ok && lib.data) ? lib.data.id : undefined;
const res = await window.api.downloadFile(url, btn.dataset.name || '', entryId);
// 传 meta:条目还不在书库时由主进程自动建,避免下载完却找不到文件
const res = await window.api.downloadFile(url, btn.dataset.name || '', entryId, undefined, entryMeta());
if (res.ok && res.data && res.data.canceled) {
btn.textContent = orig; btn.disabled = false;
return;
}
if (res.ok) {
btn.textContent = '已保存 ✓';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); btn.disabled = false; }, 2000);
} else {
if (!res.ok) {
btn.textContent = '失败';
btn.title = res.error || '';
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000);
return;
}
// 下载即入库,刷新"加入书库"按钮并就地提供打开入口
if (window.Library) window.Library.markDirty();
refreshAddButton();
const saved = res.data.path;
btn.textContent = '打开';
btn.disabled = false;
btn.classList.add('copied');
btn.onclick = async () => {
const r = await window.api.openPath(saved);
if (!r.ok) await confirmModal('打开失败', r.error || '无法打开该文件');
};
const row = btn.closest('.dl-file-row');
if (row && !row.querySelector('.reveal-btn')) {
const reveal = document.createElement('button');
reveal.className = 'copy-btn reveal-btn';
reveal.textContent = '定位';
reveal.onclick = () => window.api.showItem(saved);
row.appendChild(reveal);
}
}
+29 -7
View File
@@ -13,9 +13,21 @@ const Library = (() => {
grid = $('libGrid');
statusEl = $('libStatus');
$('addLocalBtn').onclick = addLocal;
$('rescanBtn').onclick = rescan;
window.api.library.onChanged(() => { dirty = true; refresh(true); });
}
async function rescan() {
const btn = $('rescanBtn');
btn.disabled = true;
btn.textContent = '扫描中...';
const r = await window.api.library.scan();
btn.textContent = r.ok && r.data && r.data.added ? `新增 ${r.data.added} 本 ✓` : '已是最新 ✓';
dirty = true;
await refresh(true);
setTimeout(() => { btn.textContent = '重新扫描'; btn.disabled = false; }, 2000);
}
function getSortMode() { return sortMode; }
function setSortMode(m) {
sortMode = m;
@@ -30,16 +42,20 @@ const Library = (() => {
dirty = false;
if (!res.ok) { statusEl.textContent = '加载失败:' + res.error; return; }
const items = res.data.slice().sort(SORTERS[sortMode] || SORTERS.added);
statusEl.textContent = `${items.length}`;
const missingCount = items.filter((it) => it.missing).length;
statusEl.textContent = `${items.length}` + (missingCount ? `${missingCount} 条文件缺失` : '');
if (!items.length) {
grid.innerHTML = '<div class="empty">书库为空,去「检索」页添加文献 / 图书吧</div>';
return;
}
grid.innerHTML = items.map((it) => {
const hasFile = (it.files || []).some((f) => f.path);
const badge = hasFile
// exists 由主进程按实际磁盘状态给出:文件被手动删掉时要如实反映
const openable = (it.files || []).some((f) => f.exists);
const badge = openable
? '<span class="card-badge">已下载</span>'
: '<span class="card-badge miss">未下载</span>';
: ((it.files || []).length
? '<span class="card-badge miss">文件缺失</span>'
: '<span class="card-badge miss">未下载</span>');
return `
<div class="card" data-id="${escapeHtml(it.id)}">
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
@@ -47,7 +63,8 @@ const Library = (() => {
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${badge}
<div class="lib-card-actions">
<button class="open-btn" data-act="open" ${hasFile ? '' : 'disabled'}>打开</button>
<button class="open-btn" data-act="open" ${openable ? '' : 'disabled'}>打开</button>
${openable ? '<button data-act="reveal">定位</button>' : ''}
${it.url ? '<button data-act="page">页面</button>' : ''}
<button data-act="remove">移除</button>
</div>
@@ -67,8 +84,13 @@ const Library = (() => {
if (!res.ok || !res.data) return;
const it = res.data;
if (act === 'open') {
const f = (it.files || []).find((x) => x.path);
if (f) window.api.openPath(f.path);
const f = (it.files || []).find((x) => x.exists) || (it.files || [])[0];
if (!f) return;
const r = await window.api.openPath(f.path);
if (!r.ok) await confirmModal('打开失败', r.error || '无法打开该文件');
} else if (act === 'reveal') {
const f = (it.files || []).find((x) => x.exists);
if (f) window.api.showItem(f.path);
} else if (act === 'page') {
if (it.url) window.api.openExternal(it.url);
} else if (act === 'remove') {