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
+14 -6
View File
@@ -9,6 +9,7 @@
- **全局代理**:一处配置,对所有数据源与封面请求生效
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
- **版本更新**:手动或启动时检查 GitHub Releases,发现新版本后前往下载
## 数据源
@@ -29,26 +30,33 @@
数据源的可用性取决于站点自身状态。部分站点(如 Sci-Hub)启用了人机验证,程序会给出明确提示而非静默失败。
## 环境要求
## 下载与运行
- Node.js 18+
- Windows / macOS / Linux
正式版本仅通过 [GitHub Releases](https://github.com/lofyer/peoplelib/releases) 发布打包后的 Windows x64 二进制,不通过 npm 分发。
## 快速开始
1. 下载最新版本的压缩包并完整解压。
2. 双击目录中的 `PeopleLib.exe`
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`
当前版本为 **1.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
## 源码运行与打包
源码开发需要 Node.js 18+
```bash
npm install
npm start
```
## 打包
生成 Windows 免安装版到 `dist/`
```bash
npm run portable
```
发布时将完整的 `dist/PeopleLib-1.1.0/` 目录压缩,上传到 GitHub Release,并使用 `v1.1.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
## 配置
### 代理
+27 -3
View File
@@ -2,9 +2,11 @@ const fs = require('fs');
const path = require('path');
const ROOT = __dirname;
const OUT = path.join(ROOT, 'dist', 'PeopleLib-win32-x64');
const APP = path.join(OUT, 'resources', 'app');
const pkg = require('./package.json');
const OUT = process.env.PEOPLELIB_OUT_DIR
? path.resolve(process.env.PEOPLELIB_OUT_DIR)
: path.join(ROOT, 'dist', `PeopleLib-${pkg.version}`);
const APP = path.join(OUT, 'resources', 'app');
const PRODUCT = 'PeopleLib';
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
@@ -19,6 +21,13 @@ function copyDir(src, dst, skip) {
}
}
function pruneLocales(dir) {
const keep = new Set(['zh-CN.pak', 'en-US.pak']);
for (const name of fs.readdirSync(dir)) {
if (!keep.has(name)) fs.unlinkSync(path.join(dir, name));
}
}
function skipDevFiles(e) {
const name = e.name.toLowerCase();
if (e.isDirectory()) return false;
@@ -28,12 +37,25 @@ function skipDevFiles(e) {
return false;
}
function copyUndici(dst) {
const src = path.join(ROOT, 'node_modules', 'undici');
fs.mkdirSync(dst, { recursive: true });
for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) {
const file = path.join(src, name);
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dst, name));
}
copyDir(path.join(src, 'lib'), path.join(dst, 'lib'), skipDevFiles);
}
console.log('清理输出目录...');
rimraf(OUT);
console.log('复制 Electron 运行时...');
copyDir(path.join(ROOT, 'node_modules', 'electron', 'dist'), OUT);
console.log('精简语言包...');
pruneLocales(path.join(OUT, 'locales'));
console.log('重命名可执行文件...');
fs.renameSync(path.join(OUT, 'electron.exe'), path.join(OUT, PRODUCT + '.exe'));
rimraf(path.join(OUT, 'resources', 'default_app.asar'));
@@ -43,10 +65,12 @@ fs.mkdirSync(APP, { recursive: true });
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
copyUndici(path.join(APP, 'node_modules', 'undici'));
fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
name: pkg.name, version: pkg.version, description: pkg.description,
main: 'main.js', author: pkg.author, license: pkg.license
main: 'main.js', author: pkg.author, license: pkg.license,
dependencies: { undici: pkg.dependencies.undici }
}, null, 2));
console.log('\n构建完成:');
+286 -34
View File
@@ -1,8 +1,56 @@
const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session } = require('electron');
const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session, safeStorage } = require('electron');
const path = require('path');
const fs = require('fs');
const { Readable, Transform } = require('stream');
const { pipeline } = require('stream/promises');
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 RELEASES_API = 'https://api.github.com/repos/lofyer/peoplelib/releases/latest';
const RELEASES_PAGE = 'https://github.com/lofyer/peoplelib/releases';
function parseVersion(v) {
return String(v || '').replace(/^v/i, '').trim().split('.').map((n) => parseInt(n, 10) || 0);
}
function compareVersion(a, b) {
const pa = parseVersion(a);
const pb = parseVersion(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const x = pa[i] || 0;
const y = pb[i] || 0;
if (x !== y) return x > y ? 1 : -1;
}
return 0;
}
async function checkUpdate() {
const current = app.getVersion();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 15000);
let res;
try {
res = await fetchWithProxy(RELEASES_API, {
headers: { 'User-Agent': DL_UA, 'Accept': 'application/vnd.github+json' },
signal: ac.signal
});
} catch (e) {
if (e && e.name === 'AbortError') throw new Error('检查更新超时,请检查网络或代理设置');
throw e;
} finally {
clearTimeout(timer);
}
if (!res.ok) throw new Error(`检查更新失败: ${res.status}`);
const json = await res.json();
const latest = json.tag_name || json.name || '';
return {
current,
latest: String(latest).replace(/^v/i, ''),
hasUpdate: compareVersion(latest, current) > 0,
url: json.html_url || RELEASES_PAGE,
notes: json.body || ''
};
}
function filenameFromResponse(res, fallback) {
const cd = res.headers.get('content-disposition') || '';
@@ -17,8 +65,6 @@ function filenameFromResponse(res, fallback) {
}
app.setName('PeopleLib');
// 全局忽略证书错误:访问 Z-Library / LibGen 等使用泛域名证书或经代理的站点时必需
app.commandLine.appendSwitch('ignore-certificate-errors');
const userDataDir = app.isPackaged
? path.join(path.dirname(app.getPath('exe')), 'data')
@@ -28,14 +74,40 @@ app.setPath('userData', userDataDir);
const sources = require('./src/sources');
const library = require('./src/library/store');
const zlibAuth = require('./src/sources/zlib-auth');
const semanticKey = require('./src/sources/semantic-key');
const settings = require('./src/settings');
const { setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
zlibAuth.init(userDataDir);
semanticKey.init(userDataDir, safeStorage);
settings.init(userDataDir);
// 启动时从持久化设置恢复代理
setProxy(settings.get('proxy', ''));
try {
setProxy(settings.get('proxy', ''));
} catch (e) {
console.warn('代理配置无效,已改为直连:', e.message);
setProxy('');
}
// 书库目录:默认 <userData>/library,用户可在设置中更改
const DEFAULT_LIBRARY_DIR = path.join(userDataDir, 'library');
try {
library.init(settings.get('libraryDir', '') || DEFAULT_LIBRARY_DIR);
} catch (e) {
console.warn('自定义书库目录不可用,已回退到默认目录:', e.message);
library.init(DEFAULT_LIBRARY_DIR);
}
// 首次运行时把旧版本散落在 userData 根目录的书库数据导入一次
if (!settings.get('legacyImported', false)) {
try {
library.importLegacy(userDataDir);
settings.set('legacyImported', true);
} catch (e) {
console.warn('旧书库导入失败:', e.message);
}
}
let mainWindow;
let activeDownloads = 0;
function createWindow() {
mainWindow = new BrowserWindow({
@@ -55,11 +127,13 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, 'src', 'ui', 'index.html'));
}
library.setChangeListener(() => {
function notifyLibraryChanged() {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('library:changed');
}
});
}
library.setChangeListener(notifyLibraryChanged);
app.whenReady().then(() => {
// 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求)
@@ -67,12 +141,9 @@ app.whenReady().then(() => {
if (p) {
session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
}
// 允许证书错误的请求继续(net.fetch / 渲染进程 fetch 都会触发)
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
event.preventDefault();
callback(true);
});
createWindow();
// 启动时扫描书库目录,用户手动放进 files/ 的书会自动入库
try { library.scan(); } catch (e) { /* ignore */ }
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
@@ -98,11 +169,17 @@ ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(sources.getSour
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
ipcMain.handle('proxy:get', () => ({ ok: true, data: getProxy() }));
ipcMain.handle('proxy:set', (_e, url) => {
const u = String(url || '').trim();
setProxy(u);
settings.set('proxy', u);
session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {});
return { ok: true };
const previous = getProxy();
try {
const u = String(url || '').trim();
setProxy(u);
settings.set('proxy', u);
session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {});
return { ok: true };
} catch (e) {
try { setProxy(previous); } catch (rollbackError) { /* ignore */ }
return { ok: false, error: e.message || String(e) };
}
});
// Z-Library 凭据
@@ -110,6 +187,56 @@ ipcMain.handle('zlib:hasCreds', () => ({ ok: true, data: zlibAuth.hasCreds() }))
ipcMain.handle('zlib:login', (_e, email, password) => wrap(sources.getSource('zlib').login(email, password)));
ipcMain.handle('zlib:logout', () => wrap(sources.getSource('zlib').logout()));
// Semantic Scholar API Key
ipcMain.handle('semanticScholar:keyStatus', () => ({ ok: true, data: semanticKey.status() }));
ipcMain.handle('semanticScholar:setKey', (_e, key) => wrap(Promise.resolve().then(() => semanticKey.write(key))));
ipcMain.handle('semanticScholar:clearKey', () => wrap(Promise.resolve().then(() => semanticKey.clear())));
// 书库目录管理
ipcMain.handle('library:getDir', () => ({ ok: true, data: { dir: library.getRoot(), isDefault: library.getRoot() === DEFAULT_LIBRARY_DIR } }));
ipcMain.handle('library:pickDir', async () => {
const r = await dialog.showOpenDialog(mainWindow, {
title: '选择书库目录',
defaultPath: library.getRoot(),
properties: ['openDirectory', 'createDirectory']
});
if (r.canceled || !r.filePaths.length) return { ok: true, data: null };
return { ok: true, data: r.filePaths[0] };
});
// migrate=true 时把现有数据搬到新目录,否则只切换(旧目录原样保留)
ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(Promise.resolve().then(() => {
const dest = String(dir || '').trim();
if (!dest) throw new Error('目录不能为空');
if (activeDownloads) throw new Error('请等待当前下载完成后再切换书库目录');
const previousSetting = settings.get('libraryDir', '');
const previousRoot = library.getRoot();
try {
if (migrate) {
library.migrateTo(dest);
} else {
library.init(dest);
}
const r = library.scan();
settings.set('libraryDir', dest);
if (migrate) library.finalizeMigration();
notifyLibraryChanged();
return { dir: library.getRoot(), ...r };
} catch (e) {
if (migrate) {
try { library.rollbackMigration(); } catch (rollbackError) { /* ignore */ }
} else {
try { library.init(previousRoot); } catch (rollbackError) { /* ignore */ }
}
try { settings.set('libraryDir', previousSetting); } catch (rollbackError) { /* ignore */ }
throw e;
}
})));
ipcMain.handle('library:scan', () => wrap(Promise.resolve().then(() => {
const r = library.scan();
notifyLibraryChanged();
return r;
})));
// 本地书库
ipcMain.handle('library:list', () => wrap(Promise.resolve(library.list())));
ipcMain.handle('library:get', (_e, id) => wrap(Promise.resolve(library.get(id))));
@@ -118,38 +245,158 @@ ipcMain.handle('library:add', (_e, item) => wrap(Promise.resolve(library.add(ite
ipcMain.handle('library:update', (_e, id, patch) => wrap(Promise.resolve(library.update(id, patch))));
ipcMain.handle('library:remove', (_e, id, deleteFiles) => wrap(Promise.resolve(library.remove(id, deleteFiles))));
// 下载文件:直接保存到书库下载目录或弹保存框,支持自定义 headers
ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeaders) => {
// 下载文件:默认直接存入书库目录并挂到条目上;
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeaders, meta) => {
activeDownloads++;
let partial = '';
let res = null;
let bodyHandled = false;
try {
const parsedUrl = new URL(String(url || ''));
if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接');
const askSavePath = settings.get('askSavePath', false);
const suggested = library.sanitize(suggestName || 'download.bin');
let target;
if (askSavePath) {
const save = await dialog.showSaveDialog(mainWindow, {
title: '保存文件',
defaultPath: path.join(library.filesDir(), suggested)
});
if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } };
target = save.filePath;
}
const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) };
try { headers['Referer'] = new URL(url).origin + '/'; } catch (e) { /* ignore */ }
const res = await fetchWithProxy(String(url || ''), {
redirect: 'follow',
headers
});
headers['Referer'] = parsedUrl.origin + '/';
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 30000);
try {
res = await fetchWithProxy(parsedUrl.toString(), {
redirect: 'follow',
headers,
signal: ac.signal
});
} finally {
clearTimeout(timer);
}
if (!res.ok) throw new Error(`下载失败: ${res.status}`);
const respName = filenameFromResponse(res, suggestName);
const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName);
const defaultName = (hasExt ? suggestName : respName).replace(/[\\/:*?"<>|]/g, '_');
const save = await dialog.showSaveDialog(mainWindow, { title: '保存文件', defaultPath: defaultName });
if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } };
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(save.filePath, buf);
if (entryId) library.attachFile(entryId, save.filePath);
shell.showItemInFolder(save.filePath);
return { ok: true, data: { path: save.filePath, name: path.basename(save.filePath) } };
const defaultName = library.sanitize(hasExt ? suggestName : respName);
const contentType = res.headers.get('content-type') || '';
const expectedExt = path.extname(target || defaultName).toLowerCase();
if (/text\/html|application\/json/i.test(contentType)
&& !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) {
throw new Error('下载地址返回了网页而不是文献文件');
}
if (!target) target = library.allocFilePath(defaultName);
fs.mkdirSync(path.dirname(target), { recursive: true });
partial = path.join(
path.dirname(target),
`.${path.basename(target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
);
if (!res.body) throw new Error('下载响应没有文件内容');
let transferTimer;
const refreshTransferTimer = () => {
clearTimeout(transferTimer);
transferTimer = setTimeout(() => ac.abort(), 30000);
};
const activity = new Transform({
transform(chunk, encoding, callback) {
refreshTransferTimer();
callback(null, chunk);
}
});
refreshTransferTimer();
bodyHandled = true;
try {
await pipeline(Readable.fromWeb(res.body), activity, fs.createWriteStream(partial, { flags: 'wx' }));
} finally {
clearTimeout(transferTimer);
}
if (askSavePath) {
const backup = `${target}.${process.pid}-${Date.now()}.bak`;
let backedUp = false;
try {
if (fs.existsSync(target)) {
fs.renameSync(target, backup);
backedUp = true;
}
fs.renameSync(partial, target);
partial = '';
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ }
}
} catch (e) {
try {
if (backedUp && !fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target);
} catch (rollbackError) { /* ignore */ }
throw e;
}
} else {
for (;;) {
try {
fs.linkSync(partial, target);
break;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
target = library.allocFilePath(defaultName);
}
}
try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
partial = '';
}
// 落库:优先挂到已有条目,否则用 meta 新建
let id = entryId;
if (!id && meta) {
const existing = meta.sourceId && meta.sourcePostId
? library.findBySource(meta.sourceId, meta.sourcePostId) : null;
id = existing ? existing.id : library.add(meta).id;
}
const entry = id ? library.attachFile(id, target) : null;
return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } };
} catch (e) {
if (res && res.body && !bodyHandled) {
try { await res.body.cancel(); } catch (cancelError) { /* ignore */ }
}
if (partial) {
try { fs.unlinkSync(partial); } catch (cleanupError) { /* ignore */ }
}
if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) {
return { ok: false, error: '下载超时,请检查网络或代理设置' };
}
return { ok: false, error: e.message || String(e) };
} finally {
activeDownloads--;
}
});
// 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次,
// 在资源管理器里定位该文件,而不是静默失败。
ipcMain.handle('shell:openPath', async (_e, p) => {
const err = await shell.openPath(p || '');
return err ? { ok: false, error: err } : { ok: true };
const target = String(p || '');
if (!target) return { ok: false, error: '路径为空' };
if (!fs.existsSync(target)) return { ok: false, error: '文件不存在,可能已被移动或删除' };
const err = await shell.openPath(target);
if (!err) return { ok: true };
shell.showItemInFolder(target);
return { ok: true, data: { revealed: true, reason: err } };
});
ipcMain.handle('shell:showItem', (_e, p) => { shell.showItemInFolder(p || ''); return { ok: true }; });
ipcMain.handle('shell:openExternal', async (_e, url) => {
try { await shell.openExternal(String(url || '')); return { ok: true }; }
try {
const target = new URL(String(url || ''));
if (!/^https?:$/.test(target.protocol)) throw new Error('仅允许打开 HTTP 或 HTTPS 链接');
await shell.openExternal(target.toString());
return { ok: true };
}
catch (e) { return { ok: false, error: e.message || String(e) }; }
});
@@ -164,7 +411,12 @@ ipcMain.handle('dialog:pickFile', async () => {
return { ok: true, data: { path: p, name: path.basename(p, path.extname(p)) } };
});
// 通用设置读写(目前用于"下载前询问保存位置"开关)
ipcMain.handle('settings:get', (_e, key, def) => ({ ok: true, data: settings.get(key, def) }));
ipcMain.handle('settings:set', (_e, key, value) => { settings.set(key, value); return { ok: true }; });
ipcMain.handle('app:version', () => ({ ok: true, data: app.getVersion() }));
ipcMain.handle('app:checkUpdate', () => wrap(checkUpdate()));
ipcMain.handle('copy', (_e, text) => { clipboard.writeText(String(text || '')); return { ok: true }; });
ipcMain.on('win:minimize', () => mainWindow && mainWindow.minimize());
+7 -7
View File
@@ -1,15 +1,15 @@
{
"name": "peoplelib",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "peoplelib",
"version": "1.0.0",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"undici": "^8.9.0"
"undici": "^6.21.3"
},
"devDependencies": {
"electron": "^31.0.0"
@@ -837,12 +837,12 @@
}
},
"node_modules/undici": {
"version": "8.9.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"version": "6.28.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
"node": ">=18.17"
}
},
"node_modules/undici-types": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "peoplelib",
"version": "1.0.0",
"version": "1.1.0",
"description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library",
"main": "main.js",
"author": "peoplelib",
@@ -10,7 +10,7 @@
"portable": "node build-portable.js"
},
"dependencies": {
"undici": "^8.9.0"
"undici": "^6.21.3"
},
"devDependencies": {
"electron": "^31.0.0"
+15 -1
View File
@@ -15,14 +15,27 @@ contextBridge.exposeInMainWorld('api', {
add: (item) => ipcRenderer.invoke('library:add', item),
update: (id, patch) => ipcRenderer.invoke('library:update', id, patch),
remove: (id, deleteFiles) => ipcRenderer.invoke('library:remove', id, deleteFiles),
getDir: () => ipcRenderer.invoke('library:getDir'),
pickDir: () => ipcRenderer.invoke('library:pickDir'),
setDir: (dir, migrate) => ipcRenderer.invoke('library:setDir', dir, migrate),
scan: () => ipcRenderer.invoke('library:scan'),
onChanged: (cb) => ipcRenderer.on('library:changed', () => cb())
},
downloadFile: (url, suggestName, entryId, extraHeaders) => ipcRenderer.invoke('download:file', url, suggestName, entryId, extraHeaders),
settings: {
get: (key, def) => ipcRenderer.invoke('settings:get', key, def),
set: (key, value) => ipcRenderer.invoke('settings:set', key, value)
},
downloadFile: (url, suggestName, entryId, extraHeaders, meta) => ipcRenderer.invoke('download:file', url, suggestName, entryId, extraHeaders, meta),
zlib: {
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
logout: () => ipcRenderer.invoke('zlib:logout')
},
semanticScholar: {
keyStatus: () => ipcRenderer.invoke('semanticScholar:keyStatus'),
setKey: (key) => ipcRenderer.invoke('semanticScholar:setKey', key),
clearKey: () => ipcRenderer.invoke('semanticScholar:clearKey')
},
proxy: {
get: () => ipcRenderer.invoke('proxy:get'),
set: (url) => ipcRenderer.invoke('proxy:set', url)
@@ -33,6 +46,7 @@ contextBridge.exposeInMainWorld('api', {
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
copy: (text) => ipcRenderer.invoke('copy', text),
getVersion: () => ipcRenderer.invoke('app:version'),
checkUpdate: () => ipcRenderer.invoke('app:checkUpdate'),
minimize: () => ipcRenderer.send('win:minimize'),
maximize: () => ipcRenderer.send('win:maximize'),
close: () => ipcRenderer.send('win:close')
+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') {
-6
View File
@@ -3,7 +3,6 @@ const { app, session } = require('electron');
const path = require('path');
app.setName('PeopleLib');
app.commandLine.appendSwitch('ignore-certificate-errors');
const userDataDir = path.join(app.getPath('appData'), 'PeopleLib');
app.setPath('userData', userDataDir);
@@ -83,11 +82,6 @@ app.whenReady().then(async () => {
if (p) {
await session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
}
app.on('certificate-error', (event, wc, url, err, cert, cb) => {
event.preventDefault();
cb(true);
});
const s = zlibAuth.getSession();
const creds = zlibAuth.read();
console.log('zlib creds:', creds && creds.email ? creds.email : '(无)');