feat: 完善本地书库与发布更新流程
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
de3e1d8a44
commit
b8c8d24107
+471
-52
@@ -1,17 +1,144 @@
|
||||
const { app } = require('electron');
|
||||
// 本地书库:元数据 + 文件统一存放在「书库目录」下,用户可在设置中更改。
|
||||
//
|
||||
// <书库目录>/
|
||||
// library.json 元数据索引(含 schema 版本)
|
||||
// files/ 下载的书籍文件
|
||||
// covers/ 封面缓存
|
||||
//
|
||||
// 路径存储约定:
|
||||
// - 书库目录内的文件记相对路径(files/xxx.pdf),随书库目录整体迁移仍然有效
|
||||
// - 用户「添加本地文件」时原地引用,记绝对路径,不复制
|
||||
// 对外一律返回拼好的绝对路径,渲染层不需要关心这个区别。
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { fetchWithProxy } = require('../sources/http');
|
||||
|
||||
const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
||||
|
||||
const FILE = () => path.join(app.getPath('userData'), 'library.json');
|
||||
const COVER_DIR = () => path.join(app.getPath('userData'), 'covers');
|
||||
const SCHEMA_VERSION = 2;
|
||||
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr']);
|
||||
|
||||
let rootDir = null;
|
||||
let items = null;
|
||||
let changeListener = null;
|
||||
let pendingMigration = null;
|
||||
|
||||
function setChangeListener(fn) { changeListener = typeof fn === 'function' ? fn : null; }
|
||||
function notifyChange() { if (changeListener) { try { changeListener(); } catch (e) { /* ignore */ } } }
|
||||
|
||||
// --- 目录 ---
|
||||
|
||||
function init(dir) {
|
||||
const nextRoot = path.resolve(dir);
|
||||
for (const d of [nextRoot, path.join(nextRoot, 'files'), path.join(nextRoot, 'covers')]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
rootDir = nextRoot;
|
||||
items = null;
|
||||
}
|
||||
|
||||
function getRoot() { return rootDir; }
|
||||
function filesDir() { return path.join(rootDir, 'files'); }
|
||||
function coversDir() { return path.join(rootDir, 'covers'); }
|
||||
function indexFile() { return path.join(rootDir, 'library.json'); }
|
||||
|
||||
function ensureDirs() {
|
||||
for (const d of [rootDir, filesDir(), coversDir()]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- 路径归一化 ---
|
||||
|
||||
function isWithin(base, target) {
|
||||
const rel = path.relative(path.resolve(base), path.resolve(target));
|
||||
return !!rel && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function toRelative(abs) {
|
||||
if (!abs) return '';
|
||||
if (/^(?:https?:|data:)/i.test(abs)) return abs;
|
||||
const rel = path.relative(rootDir, abs);
|
||||
// 仍在书库目录内才转相对路径;外部文件保持绝对路径
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel.split(path.sep).join('/');
|
||||
return abs;
|
||||
}
|
||||
|
||||
function toAbsolute(stored) {
|
||||
if (!stored) return '';
|
||||
if (/^(?:https?:|data:)/i.test(stored)) return stored;
|
||||
return path.isAbsolute(stored) ? stored : path.join(rootDir, stored.split('/').join(path.sep));
|
||||
}
|
||||
|
||||
// 对外输出:补上绝对路径与存在性,渲染层直接可用
|
||||
function expand(it) {
|
||||
if (!it) return it;
|
||||
const files = (it.files || []).map((f) => {
|
||||
const abs = toAbsolute(f.path);
|
||||
return { ...f, path: abs, exists: !!abs && fs.existsSync(abs) };
|
||||
});
|
||||
return { ...it, cover: toAbsolute(it.cover), files, missing: files.length > 0 && files.every((f) => !f.exists) };
|
||||
}
|
||||
|
||||
// --- 持久化 ---
|
||||
|
||||
function load() {
|
||||
if (items) return items;
|
||||
try {
|
||||
const file = indexFile();
|
||||
const backup = `${file}.bak`;
|
||||
if (!fs.existsSync(file) && fs.existsSync(backup)) fs.renameSync(backup, file);
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
// v1 是裸数组;v2 起是 { version, items }
|
||||
if (Array.isArray(raw)) items = raw;
|
||||
else if (raw && Array.isArray(raw.items)) items = raw.items;
|
||||
else throw new Error('索引格式无效');
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') items = [];
|
||||
else throw new Error(`书库索引读取失败: ${e.message || e}`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function persistTo(dir, value) {
|
||||
const dest = path.join(dir, 'library.json');
|
||||
const temp = `${dest}.tmp`;
|
||||
const backup = `${dest}.bak`;
|
||||
let backedUp = false;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(temp, JSON.stringify({ version: SCHEMA_VERSION, items: value }, null, 2), 'utf-8');
|
||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.renameSync(dest, backup);
|
||||
backedUp = true;
|
||||
}
|
||||
fs.renameSync(temp, dest);
|
||||
if (backedUp) {
|
||||
try { fs.unlinkSync(backup); } catch (cleanupError) { /* 保留备份不影响提交 */ }
|
||||
}
|
||||
} catch (e) {
|
||||
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
|
||||
try {
|
||||
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
||||
} catch (rollbackError) { /* 下次加载时会从 .bak 恢复 */ }
|
||||
throw new Error(`书库索引写入失败: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function commit(nextItems, notify = false) {
|
||||
persistTo(rootDir, nextItems);
|
||||
items = nextItems;
|
||||
if (notify) notifyChange();
|
||||
}
|
||||
|
||||
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
||||
|
||||
// --- 封面缓存 ---
|
||||
|
||||
function isRemoteCover(c) { return typeof c === 'string' && /^https?:\/\//i.test(c); }
|
||||
|
||||
function coverExt(url) {
|
||||
const m = String(url).split('?')[0].match(/\.(png|jpe?g|webp|gif|bmp)$/i);
|
||||
return m ? m[0].toLowerCase() : '.img';
|
||||
@@ -23,23 +150,29 @@ async function cacheCover(id, url) {
|
||||
if (!res.ok) return '';
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (!buf.length) return '';
|
||||
fs.mkdirSync(COVER_DIR(), { recursive: true });
|
||||
const dest = path.join(COVER_DIR(), id + coverExt(url));
|
||||
fs.mkdirSync(coversDir(), { recursive: true });
|
||||
const dest = path.join(coversDir(), id + coverExt(url));
|
||||
fs.writeFileSync(dest, buf);
|
||||
return dest;
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
async function ensureCoverCached(id) {
|
||||
const it = get(id);
|
||||
if (!it || !isRemoteCover(it.cover)) return;
|
||||
const local = await cacheCover(id, it.cover);
|
||||
if (local && get(id)) { update(id, { cover: local }); notifyChange(); }
|
||||
const raw = load().find((x) => x.id === id);
|
||||
if (!raw || !isRemoteCover(raw.cover)) return;
|
||||
const local = await cacheCover(id, raw.cover);
|
||||
if (!local) return;
|
||||
const still = load().find((x) => x.id === id);
|
||||
if (still) {
|
||||
const next = { ...still, cover: toRelative(local), updatedAt: Date.now() };
|
||||
const nextItems = load().map((x) => x.id === id ? next : x);
|
||||
commit(nextItems, true);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCoverFile(id) {
|
||||
try {
|
||||
const dir = COVER_DIR();
|
||||
const dir = coversDir();
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
if (f === id || f.startsWith(id + '.')) { try { fs.unlinkSync(path.join(dir, f)); } catch (e) { /* ignore */ } }
|
||||
@@ -47,37 +180,36 @@ function removeCoverFile(id) {
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function setChangeListener(fn) { changeListener = typeof fn === 'function' ? fn : null; }
|
||||
function notifyChange() { if (changeListener) { try { changeListener(); } catch (e) { /* ignore */ } } }
|
||||
|
||||
function load() {
|
||||
if (items) return items;
|
||||
try {
|
||||
items = JSON.parse(fs.readFileSync(FILE(), 'utf-8'));
|
||||
if (!Array.isArray(items)) items = [];
|
||||
} catch (e) { items = []; }
|
||||
return items;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
fs.mkdirSync(path.dirname(FILE()), { recursive: true });
|
||||
fs.writeFileSync(FILE(), JSON.stringify(items, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
||||
// --- 查询 ---
|
||||
|
||||
function list() {
|
||||
return load().slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
|
||||
return load().slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)).map(expand);
|
||||
}
|
||||
|
||||
function get(id) { return load().find((x) => x.id === id) || null; }
|
||||
function get(id) {
|
||||
const it = load().find((x) => x.id === id);
|
||||
return it ? expand(it) : null;
|
||||
}
|
||||
|
||||
function findBySource(sourceId, sourcePostId) {
|
||||
return load().find((x) => x.sourceId === sourceId && String(x.sourcePostId) === String(sourcePostId)) || null;
|
||||
const it = load().find((x) => x.sourceId === sourceId && String(x.sourcePostId) === String(sourcePostId));
|
||||
return it ? expand(it) : null;
|
||||
}
|
||||
|
||||
// --- 增删改 ---
|
||||
|
||||
function normalizeFile(f) {
|
||||
const abs = toAbsolute(f.path);
|
||||
return {
|
||||
path: toRelative(abs),
|
||||
name: f.name || path.basename(abs),
|
||||
format: (f.format || path.extname(abs).slice(1) || '').toUpperCase()
|
||||
};
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
load();
|
||||
const now = Date.now();
|
||||
const it = {
|
||||
id: genId(),
|
||||
title: item.title || '未命名',
|
||||
@@ -86,45 +218,332 @@ function add(item) {
|
||||
date: item.date || '',
|
||||
brief: item.brief || '',
|
||||
url: item.url || '',
|
||||
tags: item.tags || [],
|
||||
sourceId: item.sourceId || null,
|
||||
sourcePostId: item.sourcePostId != null ? String(item.sourcePostId) : null,
|
||||
files: item.files || [], // [{ path, name, format }]
|
||||
addedAt: Date.now()
|
||||
files: (item.files || []).map(normalizeFile),
|
||||
addedAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
items.push(it);
|
||||
persist();
|
||||
if (isRemoteCover(it.cover)) ensureCoverCached(it.id);
|
||||
return it;
|
||||
const nextItems = [...items, it];
|
||||
commit(nextItems);
|
||||
if (isRemoteCover(it.cover)) ensureCoverCached(it.id).catch(() => {});
|
||||
return expand(it);
|
||||
}
|
||||
|
||||
function update(id, patch) {
|
||||
load();
|
||||
const it = items.find((x) => x.id === id);
|
||||
if (!it) throw new Error('条目不存在');
|
||||
Object.assign(it, patch);
|
||||
persist();
|
||||
return it;
|
||||
const next = { ...patch };
|
||||
if (next.files) next.files = next.files.map(normalizeFile);
|
||||
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
|
||||
const updated = { ...it, ...next, updatedAt: Date.now() };
|
||||
const nextItems = items.map((x) => x.id === id ? updated : x);
|
||||
commit(nextItems);
|
||||
return expand(updated);
|
||||
}
|
||||
|
||||
function attachFile(id, filePath) {
|
||||
const it = get(id);
|
||||
if (!it) return;
|
||||
const files = (it.files || []).filter((f) => f.path !== filePath);
|
||||
files.push({ path: filePath, name: path.basename(filePath), format: (path.extname(filePath) || '').slice(1).toUpperCase() });
|
||||
update(id, { files });
|
||||
notifyChange();
|
||||
load();
|
||||
const it = items.find((x) => x.id === id);
|
||||
if (!it) return null;
|
||||
const f = normalizeFile({ path: filePath });
|
||||
const updated = {
|
||||
...it,
|
||||
files: [...(it.files || []).filter((x) => x.path !== f.path), f],
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
const nextItems = items.map((x) => x.id === id ? updated : x);
|
||||
commit(nextItems, true);
|
||||
return expand(updated);
|
||||
}
|
||||
|
||||
function remove(id, deleteFiles) {
|
||||
load();
|
||||
const it = get(id);
|
||||
if (deleteFiles && it && it.files) {
|
||||
for (const f of it.files) { try { if (f.path && fs.existsSync(f.path)) fs.unlinkSync(f.path); } catch (e) { /* ignore */ } }
|
||||
const it = items.find((x) => x.id === id);
|
||||
const nextItems = items.filter((x) => x.id !== id);
|
||||
const staged = [];
|
||||
if (deleteFiles && it) {
|
||||
try {
|
||||
for (const f of it.files || []) {
|
||||
const abs = toAbsolute(f.path);
|
||||
// 只删书库目录内的文件;用户原地引用的外部文件不动
|
||||
if (!abs || !isWithin(rootDir, abs) || !fs.existsSync(abs)) continue;
|
||||
const temp = `${abs}.deleting-${genId()}`;
|
||||
fs.renameSync(abs, temp);
|
||||
staged.push({ abs, temp });
|
||||
}
|
||||
} catch (e) {
|
||||
for (const f of staged.reverse()) {
|
||||
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
try {
|
||||
commit(nextItems);
|
||||
} catch (e) {
|
||||
for (const f of staged.reverse()) {
|
||||
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
for (const f of staged) {
|
||||
try { fs.unlinkSync(f.temp); } catch (e) { /* 文件已从书库移除,稍后可手动清理 */ }
|
||||
}
|
||||
items = items.filter((x) => x.id !== id);
|
||||
persist();
|
||||
removeCoverFile(id);
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
module.exports = { list, get, findBySource, add, update, remove, attachFile, setChangeListener };
|
||||
// --- 下载落地 ---
|
||||
|
||||
function sanitize(name) {
|
||||
return String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim() || 'download';
|
||||
}
|
||||
|
||||
// 在 files/ 下分配一个不冲突的路径
|
||||
function allocFilePath(name) {
|
||||
fs.mkdirSync(filesDir(), { recursive: true });
|
||||
const safe = sanitize(name);
|
||||
const ext = path.extname(safe);
|
||||
const base = ext ? safe.slice(0, -ext.length) : safe;
|
||||
let candidate = path.join(filesDir(), safe);
|
||||
let n = 1;
|
||||
while (fs.existsSync(candidate)) candidate = path.join(filesDir(), `${base} (${n++})${ext}`);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// --- 扫描 ---
|
||||
|
||||
// 比对 files/ 与索引:孤立文件补建条目;记录缺失的文件不删元数据(可能带用户标签)
|
||||
function scan() {
|
||||
load();
|
||||
ensureDirs();
|
||||
|
||||
const known = new Set();
|
||||
for (const it of items) {
|
||||
for (const f of it.files || []) known.add(toAbsolute(f.path));
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(filesDir(), { withFileTypes: true }); } catch (e) { entries = []; }
|
||||
|
||||
const discovered = [];
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
const staged = e.name.match(/^(.*)\.deleting-[a-z0-9]+$/i);
|
||||
if (staged) {
|
||||
const temp = path.join(filesDir(), e.name);
|
||||
const original = path.join(filesDir(), staged[1]);
|
||||
try {
|
||||
if (known.has(original) && !fs.existsSync(original)) fs.renameSync(temp, original);
|
||||
else fs.unlinkSync(temp);
|
||||
} catch (cleanupError) { /* 下次扫描重试 */ }
|
||||
continue;
|
||||
}
|
||||
const abs = path.join(filesDir(), e.name);
|
||||
if (known.has(abs)) continue;
|
||||
const ext = path.extname(e.name).slice(1).toLowerCase();
|
||||
if (!BOOK_EXT.has(ext)) continue;
|
||||
const now = Date.now();
|
||||
discovered.push({
|
||||
id: genId(),
|
||||
title: path.basename(e.name, path.extname(e.name)),
|
||||
authors: [],
|
||||
cover: '',
|
||||
date: '',
|
||||
brief: '',
|
||||
url: '',
|
||||
tags: [],
|
||||
sourceId: null,
|
||||
sourcePostId: null,
|
||||
files: [normalizeFile({ path: abs })],
|
||||
addedAt: now,
|
||||
updatedAt: now,
|
||||
importedByScan: true
|
||||
});
|
||||
added++;
|
||||
}
|
||||
|
||||
const nextItems = discovered.length ? [...items, ...discovered] : items;
|
||||
let missing = 0;
|
||||
for (const it of nextItems) {
|
||||
const has = (it.files || []).length > 0;
|
||||
if (has && (it.files || []).every((f) => !fs.existsSync(toAbsolute(f.path)))) missing++;
|
||||
}
|
||||
|
||||
if (added) {
|
||||
commit(nextItems, true);
|
||||
}
|
||||
return { added, missing, total: items.length };
|
||||
}
|
||||
|
||||
// --- 目录迁移 ---
|
||||
|
||||
// 把当前书库目录整体搬到 dest(含 library.json / files / covers)
|
||||
function migrateTo(dest) {
|
||||
if (pendingMigration) throw new Error('上一次书库迁移尚未完成');
|
||||
const src = path.resolve(rootDir);
|
||||
dest = path.resolve(dest);
|
||||
if (!src || src === dest) return { moved: 0 };
|
||||
if (isWithin(src, dest) || isWithin(dest, src)) throw new Error('新旧书库目录不能互相包含');
|
||||
load();
|
||||
|
||||
const destIndex = path.join(dest, 'library.json');
|
||||
for (const suffix of ['', '.bak', '.tmp']) {
|
||||
if (fs.existsSync(`${destIndex}${suffix}`)) {
|
||||
throw new Error('目标目录已包含书库索引或备份,请选择“直接切换”或使用空目录');
|
||||
}
|
||||
}
|
||||
for (const sub of ['files', 'covers']) {
|
||||
const targetDir = path.join(dest, sub);
|
||||
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length) {
|
||||
throw new Error(`目标目录的 ${sub} 子目录不为空,请使用空目录`);
|
||||
}
|
||||
}
|
||||
|
||||
// 相对路径不受影响;绝对路径若指向旧书库目录内,需重定位
|
||||
const migratedItems = items.map((it) => {
|
||||
const next = {
|
||||
...it,
|
||||
files: (it.files || []).map((f) => {
|
||||
if (!path.isAbsolute(f.path)) return f;
|
||||
const abs = path.resolve(f.path);
|
||||
if (!isWithin(src, abs)) return f;
|
||||
return { ...f, path: path.relative(src, abs).split(path.sep).join('/') };
|
||||
})
|
||||
};
|
||||
if (next.cover && path.isAbsolute(next.cover) && isWithin(src, next.cover)) {
|
||||
next.cover = path.relative(src, next.cover).split(path.sep).join('/');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const copied = [];
|
||||
try {
|
||||
for (const sub of ['files', 'covers']) {
|
||||
const from = path.join(src, sub);
|
||||
const to = path.join(dest, sub);
|
||||
fs.mkdirSync(to, { recursive: true });
|
||||
if (!fs.existsSync(from)) continue;
|
||||
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
const target = path.join(to, entry.name);
|
||||
fs.copyFileSync(path.join(from, entry.name), target, fs.constants.COPYFILE_EXCL);
|
||||
copied.push({ source: path.join(from, entry.name), target });
|
||||
}
|
||||
}
|
||||
persistTo(dest, migratedItems);
|
||||
} catch (e) {
|
||||
for (const f of copied) {
|
||||
try { fs.unlinkSync(f.target); } catch (cleanupError) { /* ignore */ }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
items = migratedItems;
|
||||
rootDir = dest;
|
||||
pendingMigration = { src, dest, copied };
|
||||
return { moved: copied.length };
|
||||
}
|
||||
|
||||
function finalizeMigration() {
|
||||
if (!pendingMigration) return;
|
||||
const { src, copied } = pendingMigration;
|
||||
pendingMigration = null;
|
||||
for (const f of copied) {
|
||||
try { fs.unlinkSync(f.source); } catch (e) { /* 保留重复副本 */ }
|
||||
}
|
||||
try { fs.unlinkSync(path.join(src, 'library.json')); } catch (e) { /* 保留旧索引副本 */ }
|
||||
}
|
||||
|
||||
function rollbackMigration() {
|
||||
if (!pendingMigration) return;
|
||||
const { src, dest, copied } = pendingMigration;
|
||||
pendingMigration = null;
|
||||
rootDir = src;
|
||||
items = null;
|
||||
for (const f of copied) {
|
||||
try { fs.unlinkSync(f.target); } catch (e) { /* ignore */ }
|
||||
}
|
||||
for (const suffix of ['', '.bak', '.tmp']) {
|
||||
try { fs.unlinkSync(path.join(dest, `library.json${suffix}`)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 从旧版本(元数据直接放在 userData 根目录、文件记绝对路径)导入一次
|
||||
function importLegacy(legacyDir) {
|
||||
const legacyIndex = path.join(legacyDir, 'library.json');
|
||||
if (!fs.existsSync(legacyIndex)) return { imported: 0 };
|
||||
if (path.resolve(legacyDir) === path.resolve(rootDir)) return { imported: 0 };
|
||||
|
||||
let legacyItems = [];
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(legacyIndex, 'utf-8'));
|
||||
legacyItems = Array.isArray(raw) ? raw : (raw && raw.items) || [];
|
||||
} catch (e) { return { imported: 0 }; }
|
||||
if (!legacyItems.length) return { imported: 0 };
|
||||
|
||||
load();
|
||||
const legacyKey = (x, baseDir) => {
|
||||
if (x.sourceId && x.sourcePostId != null) return `source:${x.sourceId}|${x.sourcePostId}`;
|
||||
const filePaths = (x.files || [])
|
||||
.filter((f) => f && f.path)
|
||||
.map((f) => path.resolve(baseDir, f.path))
|
||||
.sort();
|
||||
if (filePaths.length) return `files:${filePaths.join('|')}`;
|
||||
if (x.id) return `id:${x.id}`;
|
||||
return `meta:${x.title || ''}|${(x.authors || []).join('|')}|${x.addedAt || ''}`;
|
||||
};
|
||||
const seen = new Set(items.map((x) => legacyKey(x, rootDir)));
|
||||
|
||||
// 旧封面在 <legacy>/covers,搬进新书库
|
||||
const legacyCovers = path.join(legacyDir, 'covers');
|
||||
let imported = 0;
|
||||
const importedItems = [];
|
||||
for (const old of legacyItems) {
|
||||
const key = legacyKey(old, legacyDir);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
let cover = old.cover || '';
|
||||
if (cover && !isRemoteCover(cover) && fs.existsSync(cover) && isWithin(legacyCovers, cover)) {
|
||||
const dest = path.join(coversDir(), path.basename(cover));
|
||||
try { fs.mkdirSync(coversDir(), { recursive: true }); fs.copyFileSync(cover, dest); cover = toRelative(dest); }
|
||||
catch (e) { /* 保留原绝对路径 */ }
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
importedItems.push({
|
||||
id: old.id || genId(),
|
||||
title: old.title || '未命名',
|
||||
authors: old.authors || [],
|
||||
cover,
|
||||
date: old.date || '',
|
||||
brief: old.brief || '',
|
||||
url: old.url || '',
|
||||
tags: old.tags || [],
|
||||
sourceId: old.sourceId || null,
|
||||
sourcePostId: old.sourcePostId != null ? String(old.sourcePostId) : null,
|
||||
// 旧数据文件在用户自选位置,保持绝对路径原地引用
|
||||
files: (old.files || []).filter((f) => f && f.path).map(normalizeFile),
|
||||
addedAt: old.addedAt || now,
|
||||
updatedAt: now
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
|
||||
if (imported) {
|
||||
const nextItems = [...items, ...importedItems];
|
||||
commit(nextItems, true);
|
||||
}
|
||||
return { imported };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init, getRoot, filesDir, allocFilePath, sanitize,
|
||||
list, get, findBySource, add, update, remove, attachFile,
|
||||
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy, setChangeListener
|
||||
};
|
||||
|
||||
+35
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user