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:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+590 -51
View File
@@ -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; }