Files
peoplelib/src/ui/views/library.js
T
lofyer 0fd7c59e08 feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读
笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

AI 助手支持多轮会话:会话独立落盘,先取历史再写提问,
历史只发文本不重发图像,失败与取消都保留已流出的残片。

新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线),
补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md,
但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。

书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底,
修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框
去掉衬底色块,恢复原生外观。

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
2026-08-04 16:19:06 +08:00

898 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const Library = (() => {
let dirty = true;
let sortMode = localStorage.getItem('libSortMode') || 'added';
let shelves = [];
let libraryTags = [];
let selectedShelf = '';
let selectedTag = '';
let searchQuery = '';
let refreshSeq = 0;
let grid, statusEl;
let selectMode = false;
const selectedIds = new Set();
let visibleIds = [];
// 必须与 main.js 的 READABLE_EXT 保持一致。漏掉格式不会报错,
// 只是「阅读」按钮和封面点击静默消失,看上去像阅读器打不开这类文件
const READABLE_RE = /\.(pdf|epub|mobi|azw|azw3|txt|md)$/i;
const isReadableFile = (file) => !!file && file.exists
&& READABLE_RE.test(file.path || file.name || '');
const SORTERS = {
recent: (a, b) => (
(b.lastReadAt || 0) - (a.lastReadAt || 0)
|| (b.addedAt || 0) - (a.addedAt || 0)
),
added: (a, b) => (b.addedAt || 0) - (a.addedAt || 0),
title: (a, b) => String(a.title).localeCompare(String(b.title), 'zh'),
author: (a, b) => String((a.authors || [])[0] || '').localeCompare(String((b.authors || [])[0] || ''), 'zh')
};
const CARD_ICONS = {
read: '<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H11v16H6.5A2.5 2.5 0 0 0 4 21.5Z"/><path d="M20 5.5A2.5 2.5 0 0 0 17.5 3H13v16h4.5a2.5 2.5 0 0 1 2.5 2.5Z"/>',
open: '<path d="M4 7h6l2 2h8v10H4Z"/><path d="m13 14 3-3 3 3M16 11v6"/>',
reveal: '<path d="M4 6h6l2 2h8v11H4Z"/><circle cx="15" cy="13" r="2.5"/><path d="m17 15 2 2"/>',
page: '<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.2 1.2"/><path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.2-1.2"/>',
organize: '<path d="M3 5v6l8 8 8-8-8-8H5a2 2 0 0 0-2 2Z"/><circle cx="8" cy="8" r="1"/>',
remove: '<path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/>'
};
function cardAction(action, label, primary = false, disabled = false) {
return `<button class="${primary ? 'open-btn ' : ''}icon-action"
data-act="${action}" title="${label}" aria-label="${label}" ${disabled ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" aria-hidden="true">${CARD_ICONS[action]}</svg>
</button>`;
}
function init() {
grid = $('libGrid');
statusEl = $('libStatus');
$('addLocalBtn').onclick = addLocal;
$('rescanBtn').onclick = rescan;
$('addShelfBtn').onclick = addShelf;
$('addTagBtn').onclick = addTag;
$('librarySearchBtn').onclick = applySearch;
$('libraryClearSearchBtn').onclick = clearSearch;
$('librarySearchInput').onkeydown = (event) => {
if (event.key === 'Enter') applySearch();
};
$('librarySearchInput').onsearch = () => {
if (!$('librarySearchInput').value) clearSearch();
};
document.querySelectorAll('#libraryTab .library-filter[data-shelf]').forEach((button) => {
button.onclick = () => selectShelf(button.dataset.shelf || '');
});
$('librarySelectModeBtn').onclick = () => setSelectMode(!selectMode);
$('librarySelectExitBtn').onclick = () => setSelectMode(false);
$('librarySelectAll').onchange = (event) => {
if (event.target.checked) visibleIds.forEach((id) => selectedIds.add(id));
else selectedIds.clear();
syncSelectionUi();
};
$('libraryBulkOrganizeBtn').onclick = bulkOrganize;
$('libraryBulkRemoveBtn').onclick = bulkRemove;
window.api.library.onChanged(() => { dirty = true; refresh(true); });
if (window.api.reader && window.api.reader.onNotesChanged) {
window.api.reader.onNotesChanged(() => {
dirty = true;
if (!$('libraryTab').classList.contains('hidden')) 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;
localStorage.setItem('libSortMode', m);
dirty = true;
refresh(true);
}
function noteCountsOf(result) {
const counts = new Map();
if (!result || !result.ok || !result.data) return counts;
let data = result.data.counts || result.data;
if (Array.isArray(data)) {
data.forEach((item) => {
if (Array.isArray(item)) {
counts.set(String(item[0]), Math.max(0, Math.floor(Number(item[1]) || 0)));
} else if (item && (item.entryId != null || item.id != null)) {
const entryId = item.entryId == null ? item.id : item.entryId;
const count = item.count == null ? item.noteCount : item.count;
counts.set(String(entryId), Math.max(0, Math.floor(Number(count) || 0)));
}
});
return counts;
}
if (data && typeof data === 'object') {
Object.entries(data).forEach(([entryId, count]) => {
if (count && typeof count === 'object') count = count.count == null ? count.noteCount : count.count;
counts.set(String(entryId), Math.max(0, Math.floor(Number(count) || 0)));
});
}
return counts;
}
function normalizedSearch(value) {
return String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
function isSubsequence(needle, haystack) {
if (!needle || !haystack) return false;
let offset = 0;
for (const character of needle) {
offset = haystack.indexOf(character, offset);
if (offset < 0) return false;
offset++;
}
return true;
}
function matchesSearch(item, query) {
const normalized = normalizedSearch(query);
if (!normalized) return true;
const title = normalizedSearch(item.title);
const authors = normalizedSearch((item.authors || []).join(' '));
const combined = `${title} ${authors}`.trim();
return normalized.split(' ').every((token) => (
title.includes(token)
|| authors.includes(token)
|| (token.length > 1 && isSubsequence(token, combined))
));
}
function applySearch() {
searchQuery = $('librarySearchInput').value.trim();
$('libraryClearSearchBtn').classList.toggle('hidden', !searchQuery);
dirty = true;
refresh(true);
}
function clearSearch() {
searchQuery = '';
$('librarySearchInput').value = '';
$('libraryClearSearchBtn').classList.add('hidden');
dirty = true;
refresh(true);
}
function setSelectMode(on) {
selectMode = !!on;
if (!selectMode) selectedIds.clear();
$('libraryTab').classList.toggle('select-mode', selectMode);
$('librarySelectionBar').classList.toggle('hidden', !selectMode);
const toggle = $('librarySelectModeBtn');
toggle.classList.toggle('active', selectMode);
toggle.setAttribute('aria-pressed', String(selectMode));
dirty = true;
refresh(true);
}
function syncSelectionUi() {
if (!selectMode) return;
const total = visibleIds.length;
const picked = visibleIds.filter((id) => selectedIds.has(id)).length;
const all = $('librarySelectAll');
all.checked = total > 0 && picked === total;
all.indeterminate = picked > 0 && picked < total;
all.disabled = total === 0;
$('librarySelectAllLabel').textContent = all.checked ? '取消全选' : '全选';
$('librarySelectionCount').textContent = picked
? `已选择 ${picked}${picked === total ? '(当前全部)' : ''}`
: '未选择';
$('libraryBulkOrganizeBtn').disabled = !picked;
$('libraryBulkRemoveBtn').disabled = !picked;
grid.querySelectorAll(':scope > .card').forEach((card) => {
const on = selectedIds.has(card.dataset.id);
card.classList.toggle('selected', on);
const box = card.querySelector('.card-select input');
if (box) box.checked = on;
});
}
function toggleSelection(id, on) {
if (on === undefined) on = !selectedIds.has(id);
if (on) selectedIds.add(id);
else selectedIds.delete(id);
syncSelectionUi();
}
function selectedEntries(items) {
return items.filter((item) => selectedIds.has(String(item.id)));
}
function cardHtml(it, noteCounts, annotationCounts) {
const openable = (it.files || []).some((file) => file.exists);
const readable = (it.files || []).some(isReadableFile);
const badge = openable
? '<span class="card-badge">已下载</span>'
: ((it.files || []).length
? '<span class="card-badge miss">文件缺失</span>'
: '<span class="card-badge miss">未下载</span>');
const noteCount = noteCounts.get(String(it.id)) || 0;
const noteBadge = noteCount > 0
? `<span class="card-badge note-count">笔记 ${noteCount}</span>`
: '';
const annotationCount = annotationCounts.get(String(it.id)) || 0;
const annotationBadge = annotationCount > 0
? `<span class="card-badge annotation-count">批注 ${annotationCount}</span>`
: '';
const tagBadges = (it.tags || []).slice(0, 3)
.map((tag) => `<span class="library-card-tag" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`)
.join('');
const selectBox = selectMode
? `<label class="card-select" title="选择"><input type="checkbox" aria-label="选择${escapeHtml(it.title)}" /></label>`
: '';
// 多选时封面不再触发阅读,否则勾选途中容易误开阅读器
const coverActs = readable && !selectMode;
return `
<div class="card" data-id="${escapeHtml(it.id)}">
${selectBox}
<div class="card-cover${readable ? ' readable' : ''}" style="${coverStyle(it.cover)}"
data-cover-state="${it.cover ? 'ready' : 'pending'}"
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
<div class="card-cover-badges start">${badge}</div>
<div class="card-cover-badges end">${annotationBadge}${noteBadge}</div>
</div>
<div class="card-title" title="${escapeHtml(it.title)}">${escapeHtml(it.title)}</div>
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${selectMode ? '' : `<div class="lib-card-actions">
${readable ? cardAction('read', '阅读', true) : ''}
${cardAction('open', readable ? '外部打开' : '打开', !readable, !openable)}
${openable ? cardAction('reveal', '在文件夹中显示') : ''}
${it.url ? cardAction('page', '打开来源页面') : ''}
${cardAction('organize', '整理书架和标签')}
${cardAction('remove', '移除书籍')}
</div>`}
</div>`;
}
function bindCard(card) {
const id = card.dataset.id;
card.querySelectorAll('button').forEach((button) => {
button.onclick = (event) => {
event.stopPropagation();
onAction(id, button.dataset.act);
};
});
if (selectMode) {
const box = card.querySelector('.card-select input');
box.onclick = (event) => event.stopPropagation();
box.onchange = () => toggleSelection(id, box.checked);
card.onclick = () => toggleSelection(id);
return;
}
const cover = card.querySelector('.card-cover[data-act="read"]');
if (!cover) return;
cover.onclick = (event) => {
event.stopPropagation();
onAction(id, 'read');
};
cover.onkeydown = (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onAction(id, 'read');
};
}
function reconcileCards(items, noteCounts, annotationCounts) {
const existing = new Map(
Array.from(grid.querySelectorAll(':scope > .card')).map((card) => [card.dataset.id, card])
);
const keep = new Set();
items.forEach((item, index) => {
const id = String(item.id);
const markup = cardHtml(item, noteCounts, annotationCounts).trim();
let card = existing.get(id);
if (!card || card.__peoplelibMarkup !== markup) {
const template = document.createElement('template');
template.innerHTML = markup;
const replacement = template.content.firstElementChild;
replacement.__peoplelibMarkup = markup;
bindCard(replacement);
if (card) card.replaceWith(replacement);
card = replacement;
}
keep.add(id);
const expected = grid.children[index] || null;
if (expected !== card) grid.insertBefore(card, expected);
});
Array.from(grid.querySelectorAll(':scope > .card')).forEach((card) => {
if (!keep.has(card.dataset.id)) card.remove();
});
Array.from(grid.children).forEach((child) => {
if (!child.classList.contains('card')) child.remove();
});
}
async function refresh(force) {
if (!force && !dirty) return;
const currentRefresh = ++refreshSeq;
const [res, noteCountResult, annotationCountResult, shelfResult, tagResult] = await Promise.all([
window.api.library.list(),
window.api.reader.getNoteCounts().catch(() => null),
window.api.reader.getAnnotationCounts().catch(() => null),
window.api.library.listShelves(),
window.api.library.listTags()
]);
if (currentRefresh !== refreshSeq) return;
dirty = false;
if (!res.ok) { statusEl.textContent = '加载失败:' + res.error; return; }
shelves = shelfResult && shelfResult.ok && Array.isArray(shelfResult.data)
? shelfResult.data
: [];
libraryTags = tagResult && tagResult.ok && Array.isArray(tagResult.data)
? tagResult.data
: [];
if (selectedShelf && selectedShelf !== '__uncategorized__'
&& !shelves.some((shelf) => shelf.id === selectedShelf)) selectedShelf = '';
if (selectedTag && !libraryTags.some((tag) => tag.name === selectedTag)) selectedTag = '';
renderOrganizationSidebar();
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;
if (selectedTag && !(item.tags || []).some((tag) => (
String(tag).toLocaleLowerCase() === selectedTag.toLocaleLowerCase()
))) return false;
return true;
});
const items = scopedItems
.filter((item) => matchesSearch(item, searchQuery))
.sort(SORTERS[sortMode] || SORTERS.added);
const missingCount = items.filter((it) => it.missing).length;
statusEl.textContent = searchQuery
? `搜索“${searchQuery}”显示 ${items.length} 条,当前分类 ${scopedItems.length} 条`
+ `${missingCount ? `,其中 ${missingCount} 条文件缺失` : ''}`
: (items.length === allItems.length
? `共 ${allItems.length}${missingCount ? `${missingCount} 条文件缺失` : ''}`
: `显示 ${items.length} 条,共 ${allItems.length}${missingCount ? `,当前 ${missingCount} 条文件缺失` : ''}`);
// 全选只覆盖当前筛选结果;条目被筛掉或删除后,其选中状态一并作废,
// 避免对看不见的书执行批量操作
visibleIds = items.map((item) => String(item.id));
const visible = new Set(visibleIds);
Array.from(selectedIds).forEach((id) => { if (!visible.has(id)) selectedIds.delete(id); });
if (!items.length) {
grid.innerHTML = `<div class="empty">${allItems.length
? (searchQuery ? '没有匹配标题或作者的书籍' : '当前分类中没有书籍')
: '书库为空,去「检索」页添加文献 / 图书吧'}</div>`;
syncSelectionUi();
return;
}
reconcileCards(items, noteCounts, annotationCounts);
syncSelectionUi();
}
function selectShelf(id) {
selectedShelf = String(id || '');
selectedTag = '';
dirty = true;
refresh(true);
}
function selectTag(name) {
selectedTag = selectedTag === name ? '' : name;
selectedShelf = '';
dirty = true;
refresh(true);
}
function renderOrganizationSidebar() {
document.querySelectorAll('#libraryTab .library-filter[data-shelf]').forEach((button) => {
button.classList.toggle(
'active',
!selectedTag && (button.dataset.shelf || '') === selectedShelf
);
});
const shelfList = $('libraryShelfList');
shelfList.textContent = '';
shelves.forEach((shelf) => {
const row = document.createElement('div');
row.className = 'library-shelf-row';
const filter = document.createElement('button');
filter.type = 'button';
filter.className = 'library-filter';
filter.classList.toggle('active', !selectedTag && selectedShelf === shelf.id);
filter.textContent = shelf.name;
filter.title = shelf.name;
filter.onclick = () => selectShelf(shelf.id);
const actions = document.createElement('div');
actions.className = 'library-shelf-actions';
const rename = document.createElement('button');
rename.type = 'button';
rename.className = 'notes-icon-btn';
rename.textContent = '✎';
rename.title = '重命名';
rename.setAttribute('aria-label', `重命名${shelf.name}`);
rename.onclick = () => renameShelf(shelf);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'notes-icon-btn danger';
remove.textContent = '×';
remove.title = '删除';
remove.setAttribute('aria-label', `删除${shelf.name}`);
remove.onclick = () => deleteShelf(shelf);
actions.append(rename, remove);
row.append(filter, actions);
shelfList.appendChild(row);
});
const tagList = $('libraryTagList');
tagList.textContent = '';
if (!libraryTags.length) {
const empty = document.createElement('div');
empty.className = 'library-filter';
empty.textContent = '暂无标签';
tagList.appendChild(empty);
return;
}
libraryTags.forEach((tag) => {
const row = document.createElement('div');
row.className = 'library-tag-row';
const button = document.createElement('button');
button.type = 'button';
button.className = 'library-filter';
button.classList.toggle('active', selectedTag === tag.name);
const count = document.createElement('span');
count.className = 'library-filter-count';
count.textContent = String(tag.count || 0);
const name = document.createElement('span');
name.textContent = `# ${tag.name}`;
button.append(name, count);
button.onclick = () => selectTag(tag.name);
const actions = document.createElement('div');
actions.className = 'library-tag-actions';
const rename = document.createElement('button');
rename.type = 'button';
rename.className = 'notes-icon-btn';
rename.textContent = '✎';
rename.title = '重命名';
rename.setAttribute('aria-label', `重命名${tag.name}`);
rename.onclick = () => renameTag(tag);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'notes-icon-btn danger';
remove.textContent = '×';
remove.title = '删除';
remove.setAttribute('aria-label', `删除${tag.name}`);
remove.onclick = () => deleteTag(tag);
actions.append(rename, remove);
row.append(button, actions);
tagList.appendChild(row);
});
}
async function addShelf() {
const result = await openModal('新建书架', `
<p>书架是单层分类,一本书只能放在一个书架中。</p>
<input id="libraryShelfName" type="text" maxlength="100" placeholder="书架名称" />
<div id="libraryShelfError" class="note-form-error"></div>
`, async () => {
const name = $('libraryShelfName').value.trim();
if (!name) { $('libraryShelfError').textContent = '请输入书架名称'; return false; }
const response = await window.api.library.addShelf({ name });
if (!response || !response.ok) {
$('libraryShelfError').textContent = (response && response.error) || '新建失败';
return false;
}
return response.data;
});
if (!result) return;
selectedShelf = result.id;
selectedTag = '';
dirty = true;
await refresh(true);
}
async function renameShelf(shelf) {
const result = await openModal('重命名书架', `
<input id="libraryShelfName" type="text" maxlength="100" value="${escapeHtml(shelf.name)}" />
<div id="libraryShelfError" class="note-form-error"></div>
`, async () => {
const name = $('libraryShelfName').value.trim();
if (!name) { $('libraryShelfError').textContent = '请输入书架名称'; return false; }
const response = await window.api.library.updateShelf(shelf.id, { name });
if (!response || !response.ok) {
$('libraryShelfError').textContent = (response && response.error) || '重命名失败';
return false;
}
return true;
});
if (!result) return;
dirty = true;
await refresh(true);
}
async function deleteShelf(shelf) {
const ok = await confirmModal(
'删除书架',
`确定删除「${shelf.name}」吗?书籍不会被删除,将移至「未分类」。`
);
if (!ok) return;
const result = await window.api.library.removeShelf(shelf.id);
if (!result || !result.ok) {
await confirmModal('删除失败', (result && result.error) || '未知错误');
return;
}
if (selectedShelf === shelf.id) selectedShelf = '__uncategorized__';
dirty = true;
await refresh(true);
}
async function addTag() {
const result = await openModal('新建标签', `
<p>标签可以同时分配给多本书。</p>
<input id="libraryTagName" type="text" maxlength="100" placeholder="标签名称" />
<div id="libraryTagError" class="note-form-error"></div>
`, async () => {
const name = $('libraryTagName').value.trim();
if (!name) { $('libraryTagError').textContent = '请输入标签名称'; return false; }
const response = await window.api.library.addTag({ name });
if (!response || !response.ok) {
$('libraryTagError').textContent = (response && response.error) || '新建失败';
return false;
}
return response.data;
});
if (!result) return;
selectedTag = result.name;
selectedShelf = '';
dirty = true;
await refresh(true);
}
async function renameTag(tag) {
const result = await openModal('重命名标签', `
<input id="libraryTagName" type="text" maxlength="100" value="${escapeHtml(tag.name)}" />
<div id="libraryTagError" class="note-form-error"></div>
`, async () => {
const name = $('libraryTagName').value.trim();
if (!name) { $('libraryTagError').textContent = '请输入标签名称'; return false; }
const response = await window.api.library.updateTag(tag.id, { name });
if (!response || !response.ok) {
$('libraryTagError').textContent = (response && response.error) || '重命名失败';
return false;
}
return response.data;
});
if (!result) return;
if (selectedTag === tag.name) selectedTag = result.name;
dirty = true;
await refresh(true);
}
async function deleteTag(tag) {
const ok = await confirmModal(
'删除标签',
`确定删除「${tag.name}」吗?该标签会从所有书籍中移除,书籍不会被删除。`
);
if (!ok) return;
const result = await window.api.library.removeTag(tag.id);
if (!result || !result.ok) {
await confirmModal('删除失败', (result && result.error) || '未知错误');
return;
}
if (selectedTag === tag.name) selectedTag = '';
dirty = true;
await refresh(true);
}
// 批量整理:标签用三态复选框。indeterminate 表示"部分书有该标签"
// 保持这个状态就不改动这些书原有的标签;只有用户明确勾上或取消才统一应用。
async function bulkOrganize() {
const listed = await window.api.library.list();
if (!listed || !listed.ok) {
await confirmModal('整理失败', (listed && listed.error) || '无法读取书库');
return;
}
const targets = selectedEntries(listed.data || []);
if (!targets.length) return;
const shelfIds = new Set(targets.map((item) => item.shelfId || ''));
const sharedShelf = shelfIds.size === 1 ? [...shelfIds][0] : null;
const options = shelves.map((shelf) => (
`<option value="${escapeHtml(shelf.id)}"${sharedShelf === shelf.id ? ' selected' : ''}>${escapeHtml(shelf.name)}</option>`
)).join('');
const counts = new Map();
targets.forEach((item) => (item.tags || []).forEach((tag) => {
const key = String(tag).toLocaleLowerCase();
counts.set(key, (counts.get(key) || 0) + 1);
}));
const tagOptions = libraryTags.map((tag) => {
const held = counts.get(tag.name.toLocaleLowerCase()) || 0;
const state = held === 0 ? 'none' : (held === targets.length ? 'all' : 'some');
return `<label class="library-tag-option">
<input type="checkbox" value="${escapeHtml(tag.name)}" data-state="${state}"${state === 'all' ? ' checked' : ''} />
<span># ${escapeHtml(tag.name)}</span>
<span class="library-filter-count">${state === 'some' ? `${held}/${targets.length}` : (tag.count || 0)}</span>
</label>`;
}).join('');
// indeterminate 只能用 DOM 属性设置,HTML 里写不出来;
// 点击后按 部分 -> 全选 -> 全不选 -> 部分 循环,保证"保持原样"始终可回到
const primeTagBoxes = () => {
document.querySelectorAll('#libraryBulkTags input[type="checkbox"]').forEach((box) => {
if (box.dataset.state !== 'some') return;
box.indeterminate = true;
box.onclick = () => {
const phase = box.dataset.phase || 'some';
const next = phase === 'some' ? 'all' : (phase === 'all' ? 'none' : 'some');
box.dataset.phase = next;
box.indeterminate = next === 'some';
box.checked = next === 'all';
};
});
};
const pending = openModal(`批量整理 ${targets.length} 本`, `
<div class="library-organize-form">
<label>书架
<select id="libraryBulkShelf">
${sharedShelf === null ? '<option value="__keep__" selected>保持不变(所选书籍分属不同书架)</option>' : ''}
<option value=""${sharedShelf === '' ? ' selected' : ''}>未分类</option>${options}
</select>
</label>
<label>标签
<details id="libraryBulkTags" class="library-tag-picker">
<summary>选择标签(部分选中的标签保持原样)</summary>
<div class="library-tag-options">${tagOptions || '<div class="library-tag-empty">暂无标签,请先在左侧新建</div>'}</div>
</details>
</label>
<div id="libraryBulkError" class="note-form-error"></div>
</div>
`, async () => {
const boxes = Array.from(document.querySelectorAll('#libraryBulkTags input[type="checkbox"]'));
const add = [];
const strip = [];
boxes.forEach((box) => {
if (box.indeterminate) return;
if (box.checked) add.push(box.value);
else if (box.dataset.state !== 'none') strip.push(box.value.toLocaleLowerCase());
});
const shelfValue = $('libraryBulkShelf').value;
const errorEl = $('libraryBulkError');
// 一次提交:逐条 update 会把整个书库索引重写 N 遍
const patches = targets.map((item) => {
const patch = {};
if (shelfValue !== '__keep__') patch.shelfId = shelfValue || null;
const kept = (item.tags || []).filter((tag) => !strip.includes(String(tag).toLocaleLowerCase()));
const merged = kept.slice();
add.forEach((tag) => {
if (!merged.some((existing) => String(existing).toLocaleLowerCase() === tag.toLocaleLowerCase())) {
merged.push(tag);
}
});
patch.tags = merged;
return { id: item.id, patch };
});
const response = await window.api.library.updateMany(patches);
if (!response || !response.ok) {
errorEl.textContent = (response && response.error) || '保存失败';
return false;
}
return true;
});
primeTagBoxes();
if (!await pending) return;
setSelectMode(false);
}
async function bulkRemove() {
const listed = await window.api.library.list();
if (!listed || !listed.ok) {
await confirmModal('移除失败', (listed && listed.error) || '无法读取书库');
return;
}
const targets = selectedEntries(listed.data || []);
if (!targets.length) return;
const withFiles = targets.filter((item) => (item.files || []).some((file) => file.path)).length;
const preview = targets.slice(0, 5).map((item) => `<li>${escapeHtml(item.title)}</li>`).join('');
const choice = await openModal('批量移除', `
<p>确定移除以下 <b>${targets.length}</b> 本书吗?</p>
<ul class="library-bulk-preview">${preview}</ul>
${targets.length > 5 ? `<p class="muted">另有 ${targets.length - 5} 本未列出</p>` : ''}
${withFiles ? `<p style="margin-top:8px"><label><input type="checkbox" id="bulkDelFiles" /> 同时删除已下载的文件(${withFiles} 本有文件)</label></p>` : ''}
<p style="margin-top:8px"><label><input type="checkbox" id="bulkDelReadingData" /> 同时删除笔记、书签、进度和标注</label></p>
<p class="muted" style="margin-top:6px">默认保留阅读资料,移除后仍可在「我的笔记」中查看。</p>
`, () => ({
deleteFiles: !!(document.getElementById('bulkDelFiles') || {}).checked,
deleteReadingData: !!document.getElementById('bulkDelReadingData').checked
}));
if (!choice) return;
const removed = await window.api.library.removeMany(targets.map((item) => item.id), choice);
setSelectMode(false);
if (!removed || !removed.ok) {
await confirmModal('移除失败', (removed && removed.error) || '未知错误');
return;
}
statusEl.textContent = `已移除 ${(removed.data && removed.data.removed) || targets.length} 本`;
}
async function organizeBook(item) {
const options = shelves.map((shelf) => (
`<option value="${escapeHtml(shelf.id)}"${item.shelfId === shelf.id ? ' selected' : ''}>${escapeHtml(shelf.name)}</option>`
)).join('');
const selectedTags = new Set((item.tags || []).map((tag) => String(tag).toLocaleLowerCase()));
const tagOptions = libraryTags.map((tag) => (
`<label class="library-tag-option">
<input type="checkbox" value="${escapeHtml(tag.name)}"${selectedTags.has(tag.name.toLocaleLowerCase()) ? ' checked' : ''} />
<span># ${escapeHtml(tag.name)}</span>
<span class="library-filter-count">${tag.count || 0}</span>
</label>`
)).join('');
const result = await openModal('整理书籍', `
<div class="library-organize-form">
<label>书架
<select id="libraryBookShelf"><option value="">未分类</option>${options}</select>
</label>
<label>标签
<details id="libraryBookTags" class="library-tag-picker">
<summary>选择标签${item.tags && item.tags.length ? `(已选 ${item.tags.length} 个)` : ''}</summary>
<div class="library-tag-options">${tagOptions || '<div class="library-tag-empty">暂无标签,请先在左侧新建</div>'}</div>
</details>
</label>
<div id="libraryOrganizeError" class="note-form-error"></div>
</div>
`, async () => {
const tags = Array.from(
document.querySelectorAll('#libraryBookTags input[type="checkbox"]:checked')
).map((input) => input.value);
const response = await window.api.library.update(item.id, {
shelfId: $('libraryBookShelf').value || null,
tags
});
if (!response || !response.ok) {
$('libraryOrganizeError').textContent = (response && response.error) || '保存失败';
return false;
}
return true;
});
if (!result) return;
dirty = true;
await refresh(true);
}
async function onAction(id, act) {
const res = await window.api.library.get(id);
if (!res.ok || !res.data) return;
const it = res.data;
if (act === 'read') {
const files = it.files || [];
const idx = files.findIndex(isReadableFile);
const r = await window.api.reader.open(id, idx >= 0 ? idx : undefined);
if (!r.ok) await confirmModal('无法阅读', r.error || '打开阅读器失败');
} else if (act === 'open') {
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 === 'organize') {
await organizeBook(it);
} else if (act === 'remove') {
const hasFile = (it.files || []).some((f) => f.path);
const r = await openModal('移除条目', `
<p>确定移除「${escapeHtml(it.title)}」吗?</p>
${hasFile ? '<p style="margin-top:8px"><label><input type="checkbox" id="delFiles" /> 同时删除已下载的文件</label></p>' : ''}
<p style="margin-top:8px"><label><input type="checkbox" id="delReadingData" /> 同时删除笔记、书签、进度和标注</label></p>
<p class="muted" style="margin-top:6px">默认保留阅读资料,移除后仍可在「我的笔记」中查看。</p>
`, () => ({
deleteFiles: !!(document.getElementById('delFiles') || {}).checked,
deleteReadingData: !!document.getElementById('delReadingData').checked
}));
if (!r) return;
const removed = await window.api.library.remove(id, r);
if (!removed || !removed.ok) {
await confirmModal('移除失败', (removed && removed.error) || '未知错误');
return;
}
dirty = true;
refresh(true);
}
}
async function addLocal() {
const source = await openModal('添加本地内容', `
<p>可以选择一个或多个文件,也可以递归导入整个文件夹。</p>
<label class="local-import-choice">
<input type="radio" name="localImportSource" value="files" checked />
选择本地文件
</label>
<label class="local-import-choice">
<input type="radio" name="localImportSource" value="folder" />
选择本地文件夹
</label>
`, () => document.querySelector('input[name="localImportSource"]:checked').value);
if (!source) return;
const picked = await window.api.library.pickLocal(source);
if (!picked || !picked.ok) {
await confirmModal('无法导入', (picked && picked.error) || '选择本地内容失败');
return;
}
if (!picked.data) return;
const selection = picked.data;
const sample = Array.isArray(selection.sample) ? selection.sample : [];
const single = selection.count === 1 && sample[0];
const displayPaths = (selection.paths || []).slice(0, 3)
.map((value) => `<div class="settings-path">${escapeHtml(value)}</div>`).join('');
const options = await openModal('导入本地图书', `
<p>发现 <b>${selection.count}</b> 个支持的图书文件。</p>
${displayPaths}
<div class="local-import-section">
<div class="local-import-label">沿用原文件夹分类</div>
<label class="local-import-choice">
<input type="radio" name="localImportOrganization" value="none" checked />
不自动分类
</label>
<label class="local-import-choice">
<input type="radio" name="localImportOrganization" value="shelf" />
使用每个文件的上一级目录作为书架
</label>
<label class="local-import-choice">
<input type="radio" name="localImportOrganization" value="tag" />
使用每个文件的上一级目录作为标签
</label>
</div>
${single ? `
<div class="local-import-section">
<input type="text" id="localTitle" maxlength="500" placeholder="标题" value="${escapeHtml(single.name.replace(/\.[^.]+$/, ''))}" />
<input type="text" id="localAuthor" maxlength="300" placeholder="作者(可选)" />
</div>
` : ''}
<div id="localImportError" class="note-form-error"></div>
`, () => ({
organization: document.querySelector(
'input[name="localImportOrganization"]:checked'
).value,
title: single ? $('localTitle').value.trim() : '',
author: single ? $('localAuthor').value.trim() : ''
}));
if (!options) return;
const result = await window.api.library.importLocal(selection.selectionId, options);
if (!result || !result.ok) {
await confirmModal('导入失败', (result && result.error) || '未知错误');
return;
}
dirty = true;
await refresh(true);
const data = result.data || {};
statusEl.textContent = `已导入 ${data.added || 0} 本`
+ (data.skippedDuplicates ? `,跳过 ${data.skippedDuplicates} 个重复文件` : '')
+ ((data.skipped || 0) > (data.skippedDuplicates || 0)
? `,另跳过 ${(data.skipped || 0) - (data.skippedDuplicates || 0)} 个无效项`
: '');
}
function markDirty() { dirty = true; }
return { init, refresh, markDirty, getSortMode, setSortMode };
})();
window.Library = Library;