feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0
新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件, 批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、 图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文, 页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。 便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化, 避免升级后 data/ 被遗留在旧目录。 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
+590
-51
@@ -1,20 +1,64 @@
|
||||
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;
|
||||
|
||||
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 || '');
|
||||
});
|
||||
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() {
|
||||
@@ -36,54 +80,487 @@ const Library = (() => {
|
||||
refresh(true);
|
||||
}
|
||||
|
||||
async function refresh(force) {
|
||||
if (!force && !dirty) return;
|
||||
const res = await window.api.library.list();
|
||||
dirty = false;
|
||||
if (!res.ok) { statusEl.textContent = '加载失败:' + res.error; return; }
|
||||
const items = res.data.slice().sort(SORTERS[sortMode] || SORTERS.added);
|
||||
const missingCount = items.filter((it) => it.missing).length;
|
||||
statusEl.textContent = `共 ${items.length} 条` + (missingCount ? `,${missingCount} 条文件缺失` : '');
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<div class="empty">书库为空,去「检索」页添加文献 / 图书吧</div>';
|
||||
return;
|
||||
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;
|
||||
}
|
||||
grid.innerHTML = items.map((it) => {
|
||||
// exists 由主进程按实际磁盘状态给出:文件被手动删掉时要如实反映
|
||||
const openable = (it.files || []).some((f) => f.exists);
|
||||
const badge = openable
|
||||
? '<span class="card-badge">已下载</span>'
|
||||
: ((it.files || []).length
|
||||
? '<span class="card-badge miss">文件缺失</span>'
|
||||
: '<span class="card-badge miss">未下载</span>');
|
||||
return `
|
||||
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 cardHtml(it, noteCounts) {
|
||||
const openable = (it.files || []).some((file) => file.exists);
|
||||
const readable = (it.files || []).some((file) => (
|
||||
file.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(file.path || file.name || '')
|
||||
));
|
||||
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 tagBadges = (it.tags || []).slice(0, 3)
|
||||
.map((tag) => `<span class="library-card-tag">${escapeHtml(tag)}</span>`)
|
||||
.join('');
|
||||
return `
|
||||
<div class="card" data-id="${escapeHtml(it.id)}">
|
||||
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
|
||||
<div class="card-title">${escapeHtml(it.title)}</div>
|
||||
<div class="card-cover${readable ? ' readable' : ''}" style="${coverStyle(it.cover)}"
|
||||
data-cover-state="${it.cover ? 'ready' : 'pending'}"
|
||||
${readable ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</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>` : ''}
|
||||
${badge}
|
||||
${noteBadge}
|
||||
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
|
||||
<div class="lib-card-actions">
|
||||
<button class="open-btn" data-act="open" ${openable ? '' : 'disabled'}>打开</button>
|
||||
${openable ? '<button data-act="reveal">定位</button>' : ''}
|
||||
${it.url ? '<button data-act="page">页面</button>' : ''}
|
||||
<button data-act="remove">移除</button>
|
||||
${readable ? cardAction('read', '阅读', true) : ''}
|
||||
${cardAction('open', readable ? '外部打开' : '打开', !readable, !openable)}
|
||||
${openable ? cardAction('reveal', '在文件夹中显示') : ''}
|
||||
${it.url ? cardAction('page', '打开来源页面') : ''}
|
||||
${cardAction('organize', '整理书架和标签')}
|
||||
${cardAction('remove', '移除书籍')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
grid.querySelectorAll('.card').forEach((el) => {
|
||||
const id = el.dataset.id;
|
||||
el.querySelectorAll('button').forEach((btn) => {
|
||||
btn.onclick = (e) => { e.stopPropagation(); onAction(id, btn.dataset.act); };
|
||||
});
|
||||
function bindCard(card) {
|
||||
const id = card.dataset.id;
|
||||
card.querySelectorAll('button').forEach((button) => {
|
||||
button.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
onAction(id, button.dataset.act);
|
||||
};
|
||||
});
|
||||
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) {
|
||||
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).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, shelfResult, tagResult] = await Promise.all([
|
||||
window.api.library.list(),
|
||||
window.api.reader.getNoteCounts().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 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} 条文件缺失` : ''}`);
|
||||
if (!items.length) {
|
||||
grid.innerHTML = `<div class="empty">${allItems.length
|
||||
? (searchQuery ? '没有匹配标题或作者的书籍' : '当前分类中没有书籍')
|
||||
: '书库为空,去「检索」页添加文献 / 图书吧'}</div>`;
|
||||
return;
|
||||
}
|
||||
reconcileCards(items, noteCounts);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 === 'open') {
|
||||
if (act === 'read') {
|
||||
const files = it.files || [];
|
||||
const idx = files.findIndex((x) => x.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(x.path || x.name || ''));
|
||||
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);
|
||||
@@ -93,39 +570,101 @@ const Library = (() => {
|
||||
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>' : ''}
|
||||
`, () => ({ del: !!(document.getElementById('delFiles') || {}).checked }));
|
||||
<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;
|
||||
await window.api.library.remove(id, r.del);
|
||||
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 r = await window.api.pickFile();
|
||||
if (!r.ok || !r.data) return;
|
||||
const { path: p, name } = r.data;
|
||||
const res = await openModal('添加本地文件', `
|
||||
<p>文件:${escapeHtml(p)}</p>
|
||||
<input type="text" id="localTitle" placeholder="标题" value="${escapeHtml(name)}" />
|
||||
<input type="text" id="localAuthor" placeholder="作者(可选)" />
|
||||
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>
|
||||
`, () => ({
|
||||
title: (document.getElementById('localTitle').value || name).trim(),
|
||||
author: (document.getElementById('localAuthor').value || '').trim()
|
||||
organization: document.querySelector(
|
||||
'input[name="localImportOrganization"]:checked'
|
||||
).value,
|
||||
title: single ? $('localTitle').value.trim() : '',
|
||||
author: single ? $('localAuthor').value.trim() : ''
|
||||
}));
|
||||
if (!res) return;
|
||||
await window.api.library.add({
|
||||
title: res.title,
|
||||
authors: res.author ? [res.author] : [],
|
||||
files: [{ path: p, name: p.split(/[\\/]/).pop(), format: (p.split('.').pop() || '').toUpperCase() }]
|
||||
});
|
||||
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;
|
||||
refresh(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; }
|
||||
|
||||
Reference in New Issue
Block a user