diff --git a/README.md b/README.md index eb7a4ae..bbbb939 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不 2. 双击目录中的 `PeopleLib.exe`。 3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。 -当前版本为 **2.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。 +当前版本为 **2.1.1**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。 ## 开发 diff --git a/main.js b/main.js index a8d121b..086cb73 100644 --- a/main.js +++ b/main.js @@ -506,6 +506,13 @@ ipcMain.handle('library:scan', () => wrap(() => { for (const job of coverGenerator.ensureAll()) job.catch(() => {}); return r; })); +ipcMain.handle('library:removeMissing', () => wrap(async () => { + const ids = library.list() + .filter((item) => item.missing) + .map((item) => String(item.id)); + for (const id of ids) await requestReaderPurge(id); + return library.removeMissing(); +})); // 本地书库 ipcMain.handle('library:list', () => wrap(() => library.list().map((item) => ({ diff --git a/package-lock.json b/package-lock.json index 52a7bff..9fec391 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "peoplelib", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "peoplelib", - "version": "2.1.0", + "version": "2.1.1", "license": "MIT", "dependencies": { "foliate-js": "1.0.1", diff --git a/package.json b/package.json index 35dd179..0df2d56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "peoplelib", - "version": "2.1.0", + "version": "2.1.1", "description": "多源开放文献、电子书与本地书库客户端", "main": "main.js", "author": "peoplelib", diff --git a/preload.js b/preload.js index f66e56f..176bfce 100644 --- a/preload.js +++ b/preload.js @@ -73,6 +73,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.invoke('library:importLocal', selectionId, options) ), scan: () => ipcRenderer.invoke('library:scan'), + removeMissing: () => ipcRenderer.invoke('library:removeMissing'), onChanged: (cb) => { const h = () => cb(); ipcRenderer.on('library:changed', h); diff --git a/src/_test/electron/library-notes.integration.js b/src/_test/electron/library-notes.integration.js index 1180720..171c0e9 100644 --- a/src/_test/electron/library-notes.integration.js +++ b/src/_test/electron/library-notes.integration.js @@ -357,6 +357,66 @@ app.whenReady().then(async () => { .every((title) => allTitles.includes(title)) && (await js("document.getElementById('libStatus').textContent")) === '共 3 条' ); + + const rescanMissingFile = path.join(TMP, 'rescan-missing.txt'); + fs.writeFileSync(rescanMissingFile, 'rescan missing fixture'); + const rescanMissingShelf = library.addShelf({ name: 'Rescan Missing Shelf' }); + const rescanMissingBook = library.add({ + title: 'Rescan Missing Book', + shelfId: rescanMissingShelf.id, + tags: ['Rescan Missing Tag'], + files: [{ path: rescanMissingFile, name: 'rescan-missing.txt', format: 'TXT' }] + }); + await pollJs( + '缺失扫描夹具显示在书库', + `!!document.querySelector('#libGrid .card[data-id="${rescanMissingBook.id}"]')` + ); + fs.unlinkSync(rescanMissingFile); + await js("document.getElementById('rescanBtn').click()"); + await waitForModal('发现文件缺失'); + check( + '重新扫描报告缺失数量并说明保留阅读资料', + await js(`(() => { + const text = document.getElementById('modalBody').textContent; + return text.includes('1 条书库内容的文件均已缺失') + && text.includes('对应分类和标签也会一并清理') + && text.includes('笔记、书签、进度和标注会保留'); + })()`) + ); + await js("document.getElementById('modalCancel').click()"); + await pollJs( + '取消缺失清理后恢复扫描按钮', + "!document.getElementById('rescanBtn').disabled", + 5000 + ); + check( + '取消清理保留缺失书库条目', + !!library.get(rescanMissingBook.id) + && library.get(rescanMissingBook.id).missing + && library.listShelves().some((shelf) => shelf.id === rescanMissingShelf.id) + && library.listTags().some((tag) => tag.name === 'Rescan Missing Tag') + ); + + await js("document.getElementById('rescanBtn').click()"); + await waitForModal('发现文件缺失'); + await submitModal(); + await poll( + '确认后清理缺失书库条目', + () => Promise.resolve(!library.get(rescanMissingBook.id)) + ); + await pollJs( + '缺失条目清理后书库恢复', + `!document.querySelector('#libGrid .card[data-id="${rescanMissingBook.id}"]') + && document.querySelectorAll('#libGrid .card').length === 3` + ); + check( + '缺失清理移除对应空分类和标签且不影响其余内容', + library.list().length === 3 + && library.list().every((item) => item.id !== rescanMissingBook.id) + && !library.listShelves().some((shelf) => shelf.id === rescanMissingShelf.id) + && !library.listTags().some((tag) => tag.name === 'Rescan Missing Tag') + ); + await js(`(() => { document.getElementById('librarySearchInput').value = 'Rsrch'; document.getElementById('librarySearchBtn').click(); diff --git a/src/_test/library.test.js b/src/_test/library.test.js index 521e1ef..9b0bea3 100644 --- a/src/_test/library.test.js +++ b/src/_test/library.test.js @@ -335,6 +335,61 @@ test('批量移除只写一次索引,可选删除库内文件', () => { assert.deepStrictEqual(store.removeMany(['missing'], false), { removed: 0 }); }); +test('清理缺失条目时一并清理对应的空书架和标签', () => { + const root = freshRoot('remove-missing'); + const emptyShelf = store.addShelf({ name: '仅缺失书籍分类' }); + const sharedShelf = store.addShelf({ name: '仍在使用分类' }); + const unrelatedShelf = store.addShelf({ name: '无关空分类' }); + store.addTag({ name: '仅缺失书籍标签' }); + store.addTag({ name: '仍在使用标签' }); + store.addTag({ name: '无关空标签' }); + + const missingA = store.add({ + title: '缺失 A', + shelfId: emptyShelf.id, + tags: ['仅缺失书籍标签', '仍在使用标签'], + files: [{ path: path.join(root, 'missing-a.pdf') }] + }); + const missingB = store.add({ + title: '缺失 B', + shelfId: sharedShelf.id, + tags: ['仍在使用标签'], + files: [{ path: path.join(root, 'missing-b.pdf') }] + }); + const existingPath = path.join(root, 'existing.pdf'); + fs.writeFileSync(existingPath, '%PDF-1.4\n'); + const existing = store.add({ + title: '仍存在', + shelfId: sharedShelf.id, + tags: ['仍在使用标签'], + files: [{ path: existingPath }] + }); + const noFiles = store.add({ title: '无文件元数据', files: [] }); + + assert.deepStrictEqual(store.removeMissing(), { + removed: 2, + shelvesRemoved: 1, + tagsRemoved: 1 + }); + assert.strictEqual(store.get(missingA.id), null); + assert.strictEqual(store.get(missingB.id), null); + assert.ok(store.get(existing.id)); + assert.ok(store.get(noFiles.id), '没有文件的元数据条目不应按文件缺失清理'); + assert.deepStrictEqual( + store.listShelves().map((shelf) => shelf.name).sort(), + [sharedShelf.name, unrelatedShelf.name].sort() + ); + assert.deepStrictEqual( + store.listTags().map((tag) => tag.name).sort(), + ['仍在使用标签', '无关空标签'].sort() + ); + assert.deepStrictEqual(store.removeMissing(), { + removed: 0, + shelvesRemoved: 0, + tagsRemoved: 0 + }); +}); + test('explicit tag creation enforces the existing catalog limit', () => { const root = freshRoot('limit'); const now = Date.now(); diff --git a/src/_test/main.test.js b/src/_test/main.test.js index 7dbab38..31be910 100644 --- a/src/_test/main.test.js +++ b/src/_test/main.test.js @@ -73,7 +73,7 @@ test('下载校验协议,拒绝 file:// 等非 http(s)', () => { test('下载请求使用可识别且带项目地址的 User-Agent', () => { assert.match(mainSrc, /UA:\s*DL_UA[\s\S]+require\('\.\/src\/sources\/http'\)/); const httpSrc = fs.readFileSync(path.join(__dirname, '..', 'sources', 'http.js'), 'utf8'); - assert.match(httpSrc, /PeopleLib\/2\.1\.0 \(\+https:\/\/github\.com\/lofyer\/peoplelib\)/); + assert.match(httpSrc, /PeopleLib\/2\.1\.1 \(\+https:\/\/github\.com\/lofyer\/peoplelib\)/); }); test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => { @@ -108,6 +108,18 @@ test('批量整理与移除走单次索引提交,不按条目循环 IPC', () = '渲染层不应再逐条发 IPC'); }); +test('重新扫描清理缺失条目前在主进程重新核对磁盘状态', () => { + const start = mainSrc.indexOf("ipcMain.handle('library:removeMissing'"); + assert.ok(start > 0, '缺少文件缺失清理通道'); + const end = mainSrc.indexOf('ipcMain.handle(', start + 20); + const segment = mainSrc.slice(start, end < 0 ? undefined : end); + assert.match(segment, /wrap\(async \(\) =>/); + assert.match(segment, /library\.list\(\)[\s\S]*\.filter\(\(item\) => item\.missing\)/); + assert.match(segment, /await requestReaderPurge\(id\)/); + assert.match(segment, /library\.removeMissing\(\)/); + assert.doesNotMatch(segment, /readerStore\.forget|annotations\.forget|aiSessions\.forget/); +}); + test('孤立阅读资料只报告不自动删除,清理需分别勾选', () => { const start = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'"); assert.ok(start > 0, '缺少孤立资料对账通道'); diff --git a/src/_test/sources.test.js b/src/_test/sources.test.js index 8bec49a..b42a193 100644 --- a/src/_test/sources.test.js +++ b/src/_test/sources.test.js @@ -455,7 +455,7 @@ test('wikisource: 搜索使用整数偏移并携带可识别 User-Agent', async assert.strictEqual(result.items[0].subtitle, '中文'); assert.strictEqual(result.maxPage, 500, 'MediaWiki 搜索最多允许偏移到 10000 条'); assert.ok(request.url.includes('sroffset=20'), request.url); - assert.match(request.options.headers['User-Agent'], /PeopleLib\/2\.1\.0/); + assert.match(request.options.headers['User-Agent'], /PeopleLib\/2\.1\.1/); }); test('wikisource: 浏览按 continuation 令牌翻页', async () => { diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js index 2ce9354..c11e3e8 100644 --- a/src/_test/ui.test.js +++ b/src/_test/ui.test.js @@ -645,6 +645,17 @@ test('书库多选提供全选、批量整理与批量移除', () => { assert.match(library, /window\.api\.library\.updateMany\(patches\)/); }); +test('重新扫描发现文件缺失后提示清理书库条目', () => { + const library = fs.readFileSync(libFile, 'utf8'); + const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8'); + assert.match(library, /const missing = Math\.max\(0, Number\(\(r\.data \|\| \{\}\)\.missing\)/); + assert.match(library, /confirmModal\(\s*'发现文件缺失'/); + assert.match(library, /对应分类和标签也会一并清理/); + assert.match(library, /笔记、书签、进度和标注会保留/); + assert.match(library, /window\.api\.library\.removeMissing\(\)/); + assert.match(preload, /removeMissing: \(\) => ipcRenderer\.invoke\('library:removeMissing'\)/); +}); + test('卡片定位上下文不随多选状态消失', () => { const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8'); // 退出多选是同步移除 class,重绘要等 IPC;若定位上下文只在 select-mode 下建立, diff --git a/src/library/store.js b/src/library/store.js index 7bbd7d6..ab9a5e5 100644 --- a/src/library/store.js +++ b/src/library/store.js @@ -978,6 +978,38 @@ function removeMany(ids, deleteFiles) { return { removed: doomed.length }; } +function removeMissing() { + load(); + const doomed = items.filter((item) => { + const files = item.files || []; + return files.length > 0 && files.every((file) => !fs.existsSync(toAbsolute(file.path))); + }); + if (!doomed.length) return { removed: 0, shelvesRemoved: 0, tagsRemoved: 0 }; + + const ids = new Set(doomed.map((item) => item.id)); + const candidateShelves = new Set(doomed.map((item) => item.shelfId).filter(Boolean)); + const candidateTags = new Set(doomed.flatMap((item) => normalizeTags(item.tags).map(tagKey))); + const nextItems = items.filter((item) => !ids.has(item.id)); + const usedShelves = new Set(nextItems.map((item) => item.shelfId).filter(Boolean)); + const usedTags = new Set(nextItems.flatMap((item) => normalizeTags(item.tags).map(tagKey))); + const nextShelves = shelves.filter((shelf) => ( + !candidateShelves.has(shelf.id) || usedShelves.has(shelf.id) + )); + const nextTags = tags.filter((tag) => ( + !candidateTags.has(tagKey(tag.name)) || usedTags.has(tagKey(tag.name)) + )); + const shelvesRemoved = shelves.length - nextShelves.length; + const tagsRemoved = tags.length - nextTags.length; + + commit(nextItems, true, nextShelves, nextTags); + for (const item of doomed) removeCoverFile(item.id); + return { + removed: doomed.length, + shelvesRemoved, + tagsRemoved + }; +} + function remove(id, deleteFiles) { load(); const it = items.find((x) => x.id === id); @@ -1311,7 +1343,7 @@ function importLegacy(legacyDir) { module.exports = { init, getRoot, filesDir, allocFilePath, sanitize, list, get, findBySource, listShelves, listTags, - add, importLocal, update, updateMany, remove, removeMany, attachFile, + add, importLocal, update, updateMany, remove, removeMany, removeMissing, attachFile, addShelf, updateShelf, removeShelf, addTag, updateTag, removeTag, scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy, diff --git a/src/sources/http.js b/src/sources/http.js index d587abb..ceaef65 100644 --- a/src/sources/http.js +++ b/src/sources/http.js @@ -1,4 +1,4 @@ -const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 PeopleLib/2.1.0 (+https://github.com/lofyer/peoplelib)'; +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 PeopleLib/2.1.1 (+https://github.com/lofyer/peoplelib)'; const { fetch: undiciFetch, ProxyAgent } = require('undici'); // 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。 diff --git a/src/ui/views/library.js b/src/ui/views/library.js index 5e16a65..95e5e8c 100644 --- a/src/ui/views/library.js +++ b/src/ui/views/library.js @@ -84,9 +84,39 @@ const Library = (() => { btn.disabled = true; btn.textContent = '扫描中...'; const r = await window.api.library.scan(); - btn.textContent = r.ok && r.data && r.data.added ? `新增 ${r.data.added} 本 ✓` : '已是最新 ✓'; + if (!r || !r.ok) { + btn.textContent = '扫描失败'; + await confirmModal('扫描失败', (r && r.error) || '未知错误'); + setTimeout(() => { btn.textContent = '重新扫描'; btn.disabled = false; }, 2000); + return; + } + btn.textContent = r.data && r.data.added ? `新增 ${r.data.added} 本 ✓` : '已是最新 ✓'; dirty = true; await refresh(true); + const missing = Math.max(0, Number((r.data || {}).missing) || 0); + if (missing) { + const clean = await confirmModal( + '发现文件缺失', + `扫描发现 ${missing} 条书库内容的文件均已缺失,是否从书库中清理这些条目?不再被其他书籍使用的对应分类和标签也会一并清理;笔记、书签、进度和标注会保留。` + ); + if (clean) { + const removed = await window.api.library.removeMissing(); + if (!removed || !removed.ok) { + await confirmModal('清理失败', (removed && removed.error) || '未知错误'); + } else { + const count = Math.max(0, Number((removed.data || {}).removed) || 0); + const shelvesRemoved = Math.max(0, Number((removed.data || {}).shelvesRemoved) || 0); + const tagsRemoved = Math.max(0, Number((removed.data || {}).tagsRemoved) || 0); + const extras = [ + shelvesRemoved ? `${shelvesRemoved} 个分类` : '', + tagsRemoved ? `${tagsRemoved} 个标签` : '' + ].filter(Boolean); + btn.textContent = `已清理 ${count} 条${extras.length ? `、${extras.join('、')}` : ''} ✓`; + dirty = true; + await refresh(true); + } + } + } setTimeout(() => { btn.textContent = '重新扫描'; btn.disabled = false; }, 2000); }