Compare commits
5
Commits
v2.1.0
...
c2292da442
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2292da442 | ||
|
|
69c9ce1c2a | ||
|
|
c294b68e96 | ||
|
|
d6078098d6 | ||
|
|
eaf9939436 |
@@ -74,7 +74,7 @@ DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不
|
||||
2. 双击目录中的 `PeopleLib.exe`。
|
||||
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。
|
||||
|
||||
当前版本为 **2.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
|
||||
当前版本为 **2.1.3**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "peoplelib",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "peoplelib",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"foliate-js": "1.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "peoplelib",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.4",
|
||||
"description": "多源开放文献、电子书与本地书库客户端",
|
||||
"main": "main.js",
|
||||
"author": "peoplelib",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -509,6 +569,59 @@ app.whenReady().then(async () => {
|
||||
);
|
||||
readerWindow.destroy();
|
||||
await wait(200);
|
||||
check(
|
||||
'分类侧栏显示全部、未分类和各书架的真实书籍数量',
|
||||
await js(`(() => {
|
||||
const all = document.querySelector('#libraryTab .library-filter[data-shelf=""]');
|
||||
const uncategorized = document.querySelector(
|
||||
'#libraryTab .library-filter[data-shelf="__uncategorized__"]'
|
||||
);
|
||||
const shelves = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'));
|
||||
const research = shelves.find((button) => button.textContent.includes('研究书架'));
|
||||
const review = shelves.find((button) => button.textContent.includes('待复核书架'));
|
||||
const actionButtons = Array.from(
|
||||
document.querySelectorAll('.library-shelf-actions .notes-icon-btn')
|
||||
);
|
||||
return all.textContent.trim() === '全部书籍(3)'
|
||||
&& uncategorized.textContent.trim() === '未分类(1)'
|
||||
&& research.textContent.trim() === '研究书架(1)'
|
||||
&& review.textContent.trim() === '待复核书架(1)'
|
||||
&& actionButtons.every((button) => {
|
||||
const style = getComputedStyle(button);
|
||||
return style.backgroundColor === 'rgba(0, 0, 0, 0)'
|
||||
&& style.borderTopColor === 'rgba(0, 0, 0, 0)';
|
||||
});
|
||||
})()`)
|
||||
);
|
||||
const longShelfName = 'TheArtOfComputerProgramming超长分类名称';
|
||||
library.updateShelf(researchShelf.id, { name: longShelfName });
|
||||
await pollJs(
|
||||
'超长分类名称刷新',
|
||||
`Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||
.some((button) => button.firstElementChild.textContent === ${JSON.stringify(longShelfName)})`
|
||||
);
|
||||
check(
|
||||
'超长分类只省略名称并完整保留数量与操作区',
|
||||
await js(`(() => {
|
||||
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||
.find((candidate) => candidate.firstElementChild.textContent === ${JSON.stringify(longShelfName)});
|
||||
const name = button.firstElementChild;
|
||||
const count = button.querySelector('.library-filter-count');
|
||||
const actions = button.closest('.library-shelf-row').querySelector('.library-shelf-actions');
|
||||
return getComputedStyle(button).display === 'flex'
|
||||
&& getComputedStyle(name).textOverflow === 'ellipsis'
|
||||
&& name.scrollWidth > name.clientWidth
|
||||
&& count.textContent === '(1)'
|
||||
&& count.getBoundingClientRect().width > 0
|
||||
&& count.getBoundingClientRect().right <= actions.getBoundingClientRect().left + 1;
|
||||
})()`)
|
||||
);
|
||||
library.updateShelf(researchShelf.id, { name: researchShelf.name });
|
||||
await pollJs(
|
||||
'恢复研究书架名称',
|
||||
`Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||
.some((button) => button.firstElementChild.textContent === ${JSON.stringify(researchShelf.name)})`
|
||||
);
|
||||
check(
|
||||
'标签侧栏显示真实聚合计数',
|
||||
await js(`(() => {
|
||||
@@ -572,7 +685,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
await js(`(() => {
|
||||
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||
.find((candidate) => candidate.textContent.trim() === '研究书架');
|
||||
.find((candidate) => candidate.firstElementChild.textContent.trim() === '研究书架');
|
||||
button.click();
|
||||
})()`);
|
||||
await pollJs(
|
||||
@@ -621,7 +734,7 @@ app.whenReady().then(async () => {
|
||||
!!uiShelf
|
||||
&& await js(`(() => {
|
||||
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||
.find((candidate) => candidate.textContent.trim() === '界面书架');
|
||||
.find((candidate) => candidate.firstElementChild.textContent.trim() === '界面书架');
|
||||
return !!button && button.classList.contains('active')
|
||||
&& document.getElementById('libStatus').textContent === '显示 0 条,共 3 条';
|
||||
})()`)
|
||||
@@ -723,7 +836,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
await js(`(() => {
|
||||
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||
.find((candidate) => candidate.textContent.trim() === '界面书架已重命名');
|
||||
.find((candidate) => candidate.firstElementChild.textContent.trim() === '界面书架已重命名');
|
||||
button.click();
|
||||
})()`);
|
||||
await pollJs(
|
||||
@@ -799,7 +912,7 @@ app.whenReady().then(async () => {
|
||||
// 否则批量操作会误伤用户看不到的书
|
||||
await js(`(() => {
|
||||
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
|
||||
.find((candidate) => candidate.textContent.trim() === '研究书架');
|
||||
.find((candidate) => candidate.firstElementChild.textContent.trim() === '研究书架');
|
||||
button.click();
|
||||
})()`);
|
||||
await pollJs(
|
||||
|
||||
@@ -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();
|
||||
|
||||
+13
-1
@@ -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\.3 \(\+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, '缺少孤立资料对账通道');
|
||||
|
||||
@@ -16,6 +16,11 @@ test('注册表:每个源都实现完整接口', () => {
|
||||
}
|
||||
assert.ok(s.name, `${s.id} 缺 name`);
|
||||
}
|
||||
assert.strictEqual(list.find((source) => source.id === 'zlib').downloadOnDemand, true);
|
||||
assert.ok(
|
||||
list.filter((source) => source.id !== 'zlib').every((source) => source.downloadOnDemand === false),
|
||||
'只有会消耗下载额度的 Z-Library 应按点击解析'
|
||||
);
|
||||
});
|
||||
|
||||
test('注册表:开放教材与中英文维基文库已启用', () => {
|
||||
@@ -455,7 +460,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\.3/);
|
||||
});
|
||||
|
||||
test('wikisource: 浏览按 continuation 令牌翻页', async () => {
|
||||
@@ -527,7 +532,9 @@ test('zlib: postId 缺 hash 时详情仍可用', async () => {
|
||||
});
|
||||
const d = await zlib.detail('123/');
|
||||
assert.strictEqual(d.title, 'B');
|
||||
assert.strictEqual(urls.length, 1, '打开详情不应顺带请求下载接口');
|
||||
assert.ok(urls[0].includes('/eapi/book/123'), urls[0]);
|
||||
assert.ok(!urls[0].includes('/file'), '打开详情消耗了下载额度: ' + urls[0]);
|
||||
assert.ok(!urls[0].includes('/eapi/book/123/?'), '缺 hash 时不该留下尾斜杠: ' + urls[0]);
|
||||
} finally {
|
||||
auth.getSession = origSession;
|
||||
|
||||
@@ -613,6 +613,25 @@ test('书架 CRUD 强制唯一非空名称并返回深拷贝', () => {
|
||||
assert.throws(() => store.updateShelf(other.id, { name: ' 文学 ' }), /已存在/);
|
||||
});
|
||||
|
||||
test('书架列表返回每个分类当前包含的书籍数量', () => {
|
||||
freshRoot('shelf-counts');
|
||||
const research = store.addShelf('研究');
|
||||
const archive = store.addShelf('归档');
|
||||
const first = store.add({ title: 'A', shelfId: research.id });
|
||||
store.add({ title: 'B', shelfId: research.id });
|
||||
store.add({ title: '未分类' });
|
||||
|
||||
assert.deepStrictEqual(
|
||||
store.listShelves().map((shelf) => [shelf.name, shelf.count]),
|
||||
[['研究', 2], ['归档', 0]]
|
||||
);
|
||||
store.update(first.id, { shelfId: archive.id });
|
||||
assert.deepStrictEqual(
|
||||
store.listShelves().map((shelf) => [shelf.name, shelf.count]),
|
||||
[['研究', 1], ['归档', 1]]
|
||||
);
|
||||
});
|
||||
|
||||
test('删除书架只清空条目 shelfId 且组织变更触发通知', () => {
|
||||
freshRoot('shelf-remove');
|
||||
let changes = 0;
|
||||
|
||||
@@ -165,6 +165,23 @@ test('书库页提供可管理标签目录和整理多选下拉', () => {
|
||||
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
|
||||
});
|
||||
|
||||
test('全部书籍、未分类和每个书架都以内联括号显示实时数量', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
assert.match(
|
||||
html,
|
||||
/data-shelf="">[\s\S]*全部书籍[\s\S]*class="library-filter-count">(0)<\/span>/
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/data-shelf="__uncategorized__">[\s\S]*未分类[\s\S]*class="library-filter-count">(0)<\/span>/
|
||||
);
|
||||
assert.match(library, /items\.filter\(\(item\) => !item\.shelfId\)\.length/);
|
||||
assert.match(library, /count\.textContent = `(\$\{value\})`/);
|
||||
assert.match(library, /count\.textContent = `(\$\{shelf\.count \|\| 0\})`/);
|
||||
assert.match(library, /filter\.append\(name, count\)/);
|
||||
});
|
||||
|
||||
test('下载区接入全局任务中心且完成按钮使用高对比绿色底色', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||
@@ -201,6 +218,28 @@ test('下载区接入全局任务中心且完成按钮使用高对比绿色底
|
||||
assert.match(rule[1], /color:\s*#07130b/);
|
||||
});
|
||||
|
||||
test('Z-Library 进入详情不消耗下载额度,点击下载后才解析并开始任务', () => {
|
||||
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||
const openDetail = browse.slice(
|
||||
browse.indexOf('async function openDetail'),
|
||||
browse.indexOf('function sourceNameOf')
|
||||
);
|
||||
assert.match(openDetail, /if \(sourceDownloadsOnDemand\(activeSourceId\)\)/);
|
||||
assert.match(openDetail, /renderOnDemandDownload\(postId, activeSourceId, token\)/);
|
||||
assert.match(openDetail, /else\s*\{\s*loadDownload\(postId, activeSourceId, token\)/);
|
||||
|
||||
const onDemand = browse.slice(
|
||||
browse.indexOf('function renderOnDemandDownload'),
|
||||
browse.indexOf('// 当前条目的元数据')
|
||||
);
|
||||
const clickAt = onDemand.indexOf('btn.onclick = async');
|
||||
const resolveAt = onDemand.indexOf('window.api.sources.download', clickAt);
|
||||
const startAt = onDemand.indexOf('download.click()', resolveAt);
|
||||
assert.ok(clickAt >= 0 && resolveAt > clickAt, '下载地址在用户点击前被获取');
|
||||
assert.ok(startAt > resolveAt, '解析下载地址后没有立即开始真实下载');
|
||||
assert.match(onDemand, /每日免费下载额度有限,仅在点击后获取下载地址/);
|
||||
});
|
||||
|
||||
test('主窗口在设置旁提供持久化明暗主题切换', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||
@@ -602,6 +641,23 @@ test('侧栏行内操作不占据布局,选中高亮与静态筛选项等宽',
|
||||
assert.match(actionsRule[1], /position:\s*absolute/);
|
||||
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*flex;\s*flex:\s*none/);
|
||||
assert.match(css, /\.library-shelf-row \.library-filter,\s*\n\.library-tag-row \.library-filter\s*\{[^}]*padding-right:\s*48px/);
|
||||
// 分类数量紧跟名称显示为「分类(数量)」,不再像标签数量那样浮到右侧
|
||||
assert.match(css, /\.library-shelf-row \.library-filter\s*\{[^}]*display:\s*flex/);
|
||||
const shelfName = css.match(
|
||||
/\.library-shelf-row \.library-filter > span:first-child\s*\{([^}]*)\}/
|
||||
);
|
||||
assert.ok(shelfName, '缺少长分类名称的独立省略样式');
|
||||
assert.match(shelfName[1], /min-width:\s*0/);
|
||||
assert.match(shelfName[1], /overflow:\s*hidden/);
|
||||
assert.match(shelfName[1], /text-overflow:\s*ellipsis/);
|
||||
assert.match(css, /#libraryTab \.library-filter\[data-shelf\] \.library-filter-count,\s*\n\.library-shelf-row \.library-filter-count\s*\{[^}]*flex:\s*none;[^}]*float:\s*none/);
|
||||
const actionButtons = css.match(
|
||||
/\.library-shelf-actions \.notes-icon-btn,\s*\n\.library-tag-actions \.notes-icon-btn\s*\{([^}]*)\}/
|
||||
);
|
||||
assert.ok(actionButtons, '缺少侧栏操作按钮样式');
|
||||
assert.match(actionButtons[1], /background:\s*transparent/);
|
||||
assert.match(actionButtons[1], /border-color:\s*transparent/);
|
||||
assert.match(css, /\.library-shelf-actions \.notes-icon-btn:hover,[\s\S]*?background:\s*transparent;[\s\S]*?border-color:\s*transparent/);
|
||||
// 书架与标签共用同一个列表容器类,行间距不会一边有一边没有
|
||||
assert.match(css, /\.library-filter-list\s*\{[^}]*gap:\s*1px/);
|
||||
assert.match(html, /id="libraryShelfList" class="library-filter-list"/);
|
||||
@@ -645,6 +701,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 下建立,
|
||||
|
||||
+39
-2
@@ -494,7 +494,12 @@ function findBySource(sourceId, sourcePostId) {
|
||||
|
||||
function listShelves() {
|
||||
load();
|
||||
return shelves.map((shelf) => ({ ...shelf }));
|
||||
const counts = new Map();
|
||||
for (const item of items) {
|
||||
if (!item.shelfId) continue;
|
||||
counts.set(item.shelfId, (counts.get(item.shelfId) || 0) + 1);
|
||||
}
|
||||
return shelves.map((shelf) => ({ ...shelf, count: counts.get(shelf.id) || 0 }));
|
||||
}
|
||||
|
||||
function listTags() {
|
||||
@@ -978,6 +983,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 +1348,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,
|
||||
|
||||
+1
-1
@@ -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.3 (+https://github.com/lofyer/peoplelib)';
|
||||
const { fetch: undiciFetch, ProxyAgent } = require('undici');
|
||||
|
||||
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
|
||||
|
||||
@@ -36,7 +36,12 @@ const sources = [
|
||||
const byId = new Map(sources.map((s) => [s.id, s]));
|
||||
|
||||
function listSources() {
|
||||
return sources.map((s) => ({ id: s.id, name: s.name, supportsSearch: s.supportsSearch !== false }));
|
||||
return sources.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
supportsSearch: s.supportsSearch !== false,
|
||||
downloadOnDemand: s.downloadOnDemand === true
|
||||
}));
|
||||
}
|
||||
|
||||
function getSource(id) {
|
||||
|
||||
@@ -270,6 +270,7 @@ module.exports = {
|
||||
id: 'zlib',
|
||||
name: 'Z-Library',
|
||||
supportsSearch: true,
|
||||
downloadOnDemand: true,
|
||||
|
||||
// 无关键词时展示热门书目
|
||||
async list(page) {
|
||||
|
||||
+6
-2
@@ -69,8 +69,12 @@
|
||||
<button id="addShelfBtn" class="notes-icon-btn" title="新建书架" aria-label="新建书架">+</button>
|
||||
</div>
|
||||
<div class="library-filter-list">
|
||||
<button class="library-filter active" data-shelf="">全部书籍</button>
|
||||
<button class="library-filter" data-shelf="__uncategorized__">未分类</button>
|
||||
<button class="library-filter active" data-shelf="">
|
||||
<span>全部书籍</span><span class="library-filter-count">(0)</span>
|
||||
</button>
|
||||
<button class="library-filter" data-shelf="__uncategorized__">
|
||||
<span>未分类</span><span class="library-filter-count">(0)</span>
|
||||
</button>
|
||||
<div id="libraryShelfList" class="library-filter-list"></div>
|
||||
</div>
|
||||
<div class="library-sidebar-section">
|
||||
|
||||
+27
-1
@@ -714,6 +714,16 @@ body {
|
||||
.library-tag-row { position: relative; display: flex; align-items: center; }
|
||||
.library-shelf-row .library-filter,
|
||||
.library-tag-row .library-filter { flex: 1; padding-right: 48px; }
|
||||
.library-shelf-row .library-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.library-shelf-row .library-filter > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.library-shelf-actions,
|
||||
.library-tag-actions {
|
||||
position: absolute;
|
||||
@@ -727,8 +737,24 @@ body {
|
||||
.library-tag-row:hover .library-tag-actions,
|
||||
.library-tag-row:focus-within .library-tag-actions { opacity: 1; pointer-events: auto; }
|
||||
.library-shelf-actions .notes-icon-btn,
|
||||
.library-tag-actions .notes-icon-btn { width: 22px; height: 22px; font-size: 12px; }
|
||||
.library-tag-actions .notes-icon-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.library-shelf-actions .notes-icon-btn:hover,
|
||||
.library-tag-actions .notes-icon-btn:hover {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.library-filter-count { float: right; color: var(--text-dim); }
|
||||
#libraryTab .library-filter[data-shelf] .library-filter-count,
|
||||
.library-shelf-row .library-filter-count {
|
||||
flex: none;
|
||||
float: none;
|
||||
}
|
||||
.library-content { min-width: 0; }
|
||||
.library-search {
|
||||
display: flex;
|
||||
|
||||
+42
-2
@@ -314,7 +314,11 @@ const Browse = (() => {
|
||||
}
|
||||
state.currentDetail = res.data;
|
||||
renderDetail(res.data);
|
||||
loadDownload(postId, activeSourceId, token);
|
||||
if (sourceDownloadsOnDemand(activeSourceId)) {
|
||||
renderOnDemandDownload(postId, activeSourceId, token);
|
||||
} else {
|
||||
loadDownload(postId, activeSourceId, token);
|
||||
}
|
||||
refreshAddButton();
|
||||
}
|
||||
|
||||
@@ -323,6 +327,11 @@ const Browse = (() => {
|
||||
return s ? s.name : id;
|
||||
}
|
||||
|
||||
function sourceDownloadsOnDemand(id) {
|
||||
const source = state.sourceList.find((item) => item.id === id);
|
||||
return !!(source && source.downloadOnDemand);
|
||||
}
|
||||
|
||||
function renderDetail(d) {
|
||||
const tagsHtml = (d.tags || []).map((t) => {
|
||||
const idx = t.indexOf(':');
|
||||
@@ -391,7 +400,10 @@ const Browse = (() => {
|
||||
$('dlRetry').onclick = () => loadDownload(postId, sourceId, token);
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
renderDownloadResult(box, res.data);
|
||||
}
|
||||
|
||||
function renderDownloadResult(box, d) {
|
||||
let html = '';
|
||||
const files = d.files || [];
|
||||
if (files.length) {
|
||||
@@ -412,6 +424,34 @@ const Browse = (() => {
|
||||
box.querySelectorAll('[data-open]').forEach((a) => { a.onclick = (e) => { e.preventDefault(); window.api.openExternal(a.dataset.open); }; });
|
||||
}
|
||||
|
||||
function renderOnDemandDownload(postId, sourceId, token) {
|
||||
const box = $('downloadBox');
|
||||
if (!box) return;
|
||||
box.innerHTML = `<div class="dl-files"><div class="dl-file-row">
|
||||
<span class="dl-file-name">每日免费下载额度有限,仅在点击后获取下载地址</span>
|
||||
<button class="dl-btn" id="onDemandDownloadBtn">下载</button>
|
||||
</div></div>`;
|
||||
const btn = $('onDemandDownloadBtn');
|
||||
btn.onclick = async () => {
|
||||
const oldError = box.querySelector('.dl-error');
|
||||
if (oldError) oldError.remove();
|
||||
btn.disabled = true;
|
||||
btn.textContent = '获取中...';
|
||||
const res = await window.api.sources.download(sourceId, postId);
|
||||
if (!box.isConnected || token !== state.detailToken) return;
|
||||
if (!res.ok) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '重试';
|
||||
btn.title = res.error || '获取下载地址失败';
|
||||
box.insertAdjacentHTML('beforeend', `<div class="dl-error">获取失败:${escapeHtml(res.error)}</div>`);
|
||||
return;
|
||||
}
|
||||
renderDownloadResult(box, res.data);
|
||||
const download = box.querySelector('.dl-btn[data-file]');
|
||||
if (download) download.click();
|
||||
};
|
||||
}
|
||||
|
||||
// 当前条目的元数据,供下载时自动建库用
|
||||
function entryMeta() {
|
||||
const d = state.currentDetail || {};
|
||||
|
||||
+49
-6
@@ -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);
|
||||
}
|
||||
|
||||
@@ -342,10 +372,10 @@ const Library = (() => {
|
||||
if (selectedShelf && selectedShelf !== '__uncategorized__'
|
||||
&& !shelves.some((shelf) => shelf.id === selectedShelf)) selectedShelf = '';
|
||||
if (selectedTag && !libraryTags.some((tag) => tag.name === selectedTag)) selectedTag = '';
|
||||
renderOrganizationSidebar();
|
||||
const allItems = res.data.slice();
|
||||
renderOrganizationSidebar(allItems);
|
||||
const noteCounts = noteCountsOf(noteCountResult);
|
||||
const annotationCounts = noteCountsOf(annotationCountResult);
|
||||
const allItems = res.data.slice();
|
||||
const scopedItems = allItems.filter((item) => {
|
||||
if (selectedShelf === '__uncategorized__' && item.shelfId) return false;
|
||||
if (selectedShelf && selectedShelf !== '__uncategorized__' && item.shelfId !== selectedShelf) return false;
|
||||
@@ -394,12 +424,20 @@ const Library = (() => {
|
||||
refresh(true);
|
||||
}
|
||||
|
||||
function renderOrganizationSidebar() {
|
||||
function renderOrganizationSidebar(items) {
|
||||
document.querySelectorAll('#libraryTab .library-filter[data-shelf]').forEach((button) => {
|
||||
const shelfId = button.dataset.shelf || '';
|
||||
button.classList.toggle(
|
||||
'active',
|
||||
!selectedTag && (button.dataset.shelf || '') === selectedShelf
|
||||
!selectedTag && shelfId === selectedShelf
|
||||
);
|
||||
const count = button.querySelector('.library-filter-count');
|
||||
if (count) {
|
||||
const value = shelfId === '__uncategorized__'
|
||||
? items.filter((item) => !item.shelfId).length
|
||||
: items.length;
|
||||
count.textContent = `(${value})`;
|
||||
}
|
||||
});
|
||||
const shelfList = $('libraryShelfList');
|
||||
shelfList.textContent = '';
|
||||
@@ -410,8 +448,13 @@ const Library = (() => {
|
||||
filter.type = 'button';
|
||||
filter.className = 'library-filter';
|
||||
filter.classList.toggle('active', !selectedTag && selectedShelf === shelf.id);
|
||||
filter.textContent = shelf.name;
|
||||
filter.title = shelf.name;
|
||||
const name = document.createElement('span');
|
||||
name.textContent = shelf.name;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'library-filter-count';
|
||||
count.textContent = `(${shelf.count || 0})`;
|
||||
filter.append(name, count);
|
||||
filter.onclick = () => selectShelf(shelf.id);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
|
||||
Reference in New Issue
Block a user