From b8c8d2410795d8f9eeb50ca5034fe9587541ea00 Mon Sep 17 00:00:00 2001 From: lofyer Date: Tue, 28 Jul 2026 21:55:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=B9=A6=E5=BA=93=E4=B8=8E=E5=8F=91=E5=B8=83=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 20 +- build-portable.js | 30 +- main.js | 320 +++++++++++++++++--- package-lock.json | 14 +- package.json | 4 +- preload.js | 16 +- src/library/store.js | 523 +++++++++++++++++++++++++++++---- src/settings.js | 40 ++- src/sources/biorxiv.js | 26 +- src/sources/doaj.js | 12 +- src/sources/http.js | 116 +------- src/sources/motw.js | 8 +- src/sources/pmc.js | 23 +- src/sources/semantic-key.js | 98 ++++++ src/sources/semanticscholar.js | 146 ++++++++- src/sources/standardebooks.js | 162 ++++++---- src/ui/app.js | 124 +++++++- src/ui/index.html | 44 ++- src/ui/style.css | 59 +++- src/ui/views/browse.js | 111 ++++--- src/ui/views/library.js | 36 ++- test-search.js | 6 - 22 files changed, 1579 insertions(+), 359 deletions(-) create mode 100644 src/sources/semantic-key.js diff --git a/README.md b/README.md index 20aee76..69e1ae4 100644 --- a/README.md +++ b/README.md @@ -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 标签判断是否需要更新。 + ## 配置 ### 代理 diff --git a/build-portable.js b/build-portable.js index 078cf07..0698fa3 100644 --- a/build-portable.js +++ b/build-portable.js @@ -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构建完成:'); diff --git a/main.js b/main.js index 56e2a3d..fccc7e1 100644 --- a/main.js +++ b/main.js @@ -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(''); +} + +// 书库目录:默认 /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()); diff --git a/package-lock.json b/package-lock.json index e943363..2a81a83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index a2338a4..f4d6202 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/preload.js b/preload.js index 41b2306..7b50698 100644 --- a/preload.js +++ b/preload.js @@ -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') diff --git a/src/library/store.js b/src/library/store.js index 7f8c154..59d4037 100644 --- a/src/library/store.js +++ b/src/library/store.js @@ -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))); + + // 旧封面在 /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 +}; diff --git a/src/settings.js b/src/settings.js index 2fd46c7..980dcf5 100644 --- a/src/settings.js +++ b/src/settings.js @@ -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 }; diff --git a/src/sources/biorxiv.js b/src/sources/biorxiv.js index 0c838e1..2b89387 100644 --- a/src/sources/biorxiv.js +++ b/src/sources/biorxiv.js @@ -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() { diff --git a/src/sources/doaj.js b/src/sources/doaj.js index 42b1fa7..c9d59c5 100644 --- a/src/sources/doaj.js +++ b/src/sources/doaj.js @@ -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 }; } }; diff --git a/src/sources/http.js b/src/sources/http.js index d2f7461..b5b96dd 100644 --- a/src/sources/http.js +++ b/src/sources/http.js @@ -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 -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> diff --git a/src/sources/motw.js b/src/sources/motw.js index 6a452a1..4e6da63 100644 --- a/src/sources/motw.js +++ b/src/sources/motw.js @@ -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('搜索请求失败'); diff --git a/src/sources/pmc.js b/src/sources/pmc.js index 0f02602..cb0be50 100644 --- a/src/sources/pmc.js +++ b/src/sources/pmc.js @@ -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(`${pmcid}\\.(\\d+)/`, '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 }] }; } diff --git a/src/sources/semantic-key.js b/src/sources/semantic-key.js new file mode 100644 index 0000000..2b2ea6f --- /dev/null +++ b/src/sources/semantic-key.js @@ -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 }; diff --git a/src/sources/semanticscholar.js b/src/sources/semanticscholar.js index 80b81c0..f49c365 100644 --- a/src/sources/semanticscholar.js +++ b/src/sources/semanticscholar.js @@ -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' }); diff --git a/src/sources/standardebooks.js b/src/sources/standardebooks.js index 34168f5..8b4a427 100644 --- a/src/sources/standardebooks.js +++ b/src/sources/standardebooks.js @@ -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 = /([\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 = /)/g; let m; - while ((m = re.exec(xml))) { - const e = m[1]; - const title = decodeEntities((e.match(/([\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: [] + }; } }; diff --git a/src/ui/app.js b/src/ui/app.js index 64821dc..30d03cb 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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'); diff --git a/src/ui/index.html b/src/ui/index.html index 12496bf..368f5a8 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -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> diff --git a/src/ui/style.css b/src/ui/style.css index 55d1dee..08b337e 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -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 { diff --git a/src/ui/views/browse.js b/src/ui/views/browse.js index 5ec3474..0739903 100644 --- a/src/ui/views/browse.js +++ b/src/ui/views/browse.js @@ -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); } } diff --git a/src/ui/views/library.js b/src/ui/views/library.js index aa779ea..7e16baf 100644 --- a/src/ui/views/library.js +++ b/src/ui/views/library.js @@ -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') { diff --git a/test-search.js b/test-search.js index f6b9284..f8b2b61 100644 --- a/test-search.js +++ b/test-search.js @@ -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 : '(无)');