笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
898 lines
37 KiB
JavaScript
898 lines
37 KiB
JavaScript
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;
|