const Notes = (() => { const UNCATEGORIZED = '__uncategorized__'; const SOURCE_LABELS = { ai: 'AI', annotation: '批注', highlight: '高亮', note: '笔记', reader: '阅读器', selection: '摘录', manual: '人工', user: '手写' }; let dirty = true; let initialized = false; let requestId = 0; let allNotes = []; let collections = []; let selectedCollection = ''; let selectedSource = ''; let selectedTag = ''; let selectedNoteType = ''; let searchText = ''; let openWindowIds = new Set(); let listEl; let statusEl; let collectionListEl; let tagFiltersEl; let sourceSelectEl; function sourceLabel(source) { const value = String(source || '').trim(); return SOURCE_LABELS[value.toLowerCase()] || value || '笔记'; } function noteTags(note) { if (Array.isArray(note.tags)) { return note.tags.map((tag) => String(tag || '').trim()).filter(Boolean); } if (typeof note.tags === 'string') { return note.tags.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean); } return []; } function dataList(result, key) { if (!result || !result.ok) return []; if (Array.isArray(result.data)) return result.data; if (result.data && Array.isArray(result.data[key])) return result.data[key]; if (result.data && Array.isArray(result.data.items)) return result.data.items; return []; } function errorText(error, fallback) { if (!error) return fallback; return typeof error === 'string' ? error : (error.message || fallback); } function init() { if (initialized) return; initialized = true; listEl = $('notesList'); statusEl = $('notesStatus'); collectionListEl = $('notesCollectionList'); tagFiltersEl = $('notesTagFilters'); sourceSelectEl = $('notesSourceSelect'); $('addCollectionBtn').onclick = addCollection; $('addGlobalNoteBtn').onclick = addGlobalNote; $('notesSearchBtn').onclick = applySearch; $('notesClearSearchBtn').onclick = () => { $('notesSearchInput').value = ''; applySearch(); }; $('notesSearchInput').onkeydown = (event) => { if (event.key === 'Enter') applySearch(); }; sourceSelectEl.onchange = () => { selectedSource = sourceSelectEl.value; render(); }; document.querySelectorAll('#notesTab .notes-collection[data-collection]').forEach((button) => { button.onclick = () => selectCollection(button.dataset.collection || ''); }); document.querySelectorAll('#notesTypeTabs .notes-type-tab').forEach((button) => { button.onclick = () => { selectedNoteType = button.dataset.noteType || ''; document.querySelectorAll('#notesTypeTabs .notes-type-tab').forEach((item) => { const active = (item.dataset.noteType || '') === selectedNoteType; item.classList.toggle('active', active); item.setAttribute('aria-selected', String(active)); }); render(); }; }); if (window.api.notes && window.api.notes.onWindowsChanged) { window.api.notes.onWindowsChanged((ids) => { openWindowIds = new Set((Array.isArray(ids) ? ids : []).map((id) => String(id))); render(); }); refreshOpenWindows(); } } async function refreshOpenWindows() { if (!window.api.notes || !window.api.notes.openWindows) return; let res; try { res = await window.api.notes.openWindows(); } catch (error) { return; } if (!res || !res.ok || !Array.isArray(res.data)) return; openWindowIds = new Set(res.data.map((id) => String(id))); render(); } function markDirty() { dirty = true; } async function refresh(force) { if (!initialized || (!force && !dirty)) return; dirty = false; const currentRequest = ++requestId; statusEl.textContent = '正在加载笔记...'; let noteResult; let collectionResult; try { [noteResult, collectionResult] = await Promise.all([ window.api.reader.listNotes({}), window.api.reader.listCollections() ]); } catch (error) { if (currentRequest !== requestId) return; dirty = true; statusEl.textContent = '加载失败:' + errorText(error, '未知错误'); return; } if (currentRequest !== requestId) return; if (!noteResult || !noteResult.ok) { dirty = true; statusEl.textContent = '加载失败:' + errorText(noteResult && noteResult.error, '未知错误'); return; } allNotes = dataList(noteResult, 'notes'); if (collectionResult && collectionResult.ok) { collections = dataList(collectionResult, 'collections'); } else { collections = []; } if (selectedCollection && selectedCollection !== UNCATEGORIZED && !collections.some((collection) => String(collection.id) === selectedCollection)) { selectedCollection = ''; } renderCollections(); renderSourceOptions(); renderTagFilters(); render(); } function applySearch() { searchText = $('notesSearchInput').value.trim(); $('notesClearSearchBtn').classList.toggle('hidden', !searchText); render(); } function selectCollection(collectionId) { selectedCollection = String(collectionId || ''); selectedTag = ''; renderCollections(); renderTagFilters(); render(); } function renderCollections() { document.querySelectorAll('#notesTab .notes-collection[data-collection]').forEach((button) => { button.classList.toggle('active', (button.dataset.collection || '') === selectedCollection); }); collectionListEl.textContent = ''; collections.forEach((collection) => { const id = String(collection.id || ''); if (!id) return; const row = document.createElement('div'); row.className = 'notes-collection-row'; const filterButton = document.createElement('button'); filterButton.className = 'notes-collection'; filterButton.classList.toggle('active', selectedCollection === id); filterButton.textContent = collection.name || '未命名笔记本'; filterButton.title = filterButton.textContent; filterButton.onclick = () => selectCollection(id); const actions = document.createElement('div'); actions.className = 'notes-collection-actions'; const renameButton = document.createElement('button'); renameButton.className = 'notes-icon-btn'; renameButton.type = 'button'; renameButton.title = '重命名'; renameButton.setAttribute('aria-label', `重命名${filterButton.textContent}`); renameButton.textContent = '✎'; renameButton.onclick = (event) => { event.stopPropagation(); renameCollection(collection); }; const deleteButton = document.createElement('button'); deleteButton.className = 'notes-icon-btn danger'; deleteButton.type = 'button'; deleteButton.title = '删除'; deleteButton.setAttribute('aria-label', `删除${filterButton.textContent}`); deleteButton.textContent = '×'; deleteButton.onclick = (event) => { event.stopPropagation(); deleteCollection(collection); }; actions.append(renameButton, deleteButton); row.append(filterButton, actions); collectionListEl.appendChild(row); }); } function renderSourceOptions() { const sources = Array.from(new Set(allNotes .map((note) => String(note.source || '').trim()) .filter(Boolean))) .sort((a, b) => sourceLabel(a).localeCompare(sourceLabel(b), 'zh')); if (selectedSource && !sources.includes(selectedSource)) selectedSource = ''; sourceSelectEl.textContent = ''; const allOption = document.createElement('option'); allOption.value = ''; allOption.textContent = '全部来源'; sourceSelectEl.appendChild(allOption); sources.forEach((source) => { const option = document.createElement('option'); option.value = source; option.textContent = sourceLabel(source); sourceSelectEl.appendChild(option); }); sourceSelectEl.value = selectedSource; } function availableTags() { const tags = new Map(); allNotes.forEach((note) => noteTags(note).forEach((tag) => { const key = tag.toLocaleLowerCase(); if (!tags.has(key)) tags.set(key, tag); })); return Array.from(tags.values()).sort((a, b) => a.localeCompare(b, 'zh')); } function renderTagFilters() { const tags = availableTags(); if (selectedTag) { selectedTag = tags.find((tag) => ( tag.toLocaleLowerCase() === selectedTag.toLocaleLowerCase() )) || ''; } tagFiltersEl.textContent = ''; tagFiltersEl.classList.toggle('hidden', !tags.length); if (!tags.length) return; const label = document.createElement('span'); label.className = 'notes-tags-label'; label.textContent = '标签'; tagFiltersEl.appendChild(label); tags.forEach((tag) => { const button = document.createElement('button'); button.className = 'note-tag'; button.classList.toggle('active', selectedTag === tag); button.type = 'button'; button.textContent = tag; button.onclick = () => { selectedTag = selectedTag === tag ? '' : tag; renderTagFilters(); render(); }; tagFiltersEl.appendChild(button); }); } function visibleNotes() { const needle = searchText.toLocaleLowerCase('zh-CN'); return allNotes.filter((note) => { const collectionId = note.collectionId == null ? '' : String(note.collectionId); if (selectedCollection === UNCATEGORIZED && collectionId) return false; if (selectedCollection && selectedCollection !== UNCATEGORIZED && collectionId !== selectedCollection) return false; if (selectedSource && String(note.source || '') !== selectedSource) return false; if (selectedNoteType && String(note.noteType || 'reading') !== selectedNoteType) return false; if (selectedTag && !noteTags(note).some((tag) => ( tag.toLocaleLowerCase() === selectedTag.toLocaleLowerCase() ))) return false; if (!needle) return true; const book = note.bookSnapshot || note.book || {}; const haystack = [ note.title, note.text, note.quote, note.context, note.source, book.title, ...(Array.isArray(book.authors) ? book.authors : []), ...noteTags(note) ].map((value) => String(value || '')).join('\n').toLocaleLowerCase('zh-CN'); return haystack.includes(needle); }).sort((a, b) => { const pinnedOrder = Number(!!b.pinned) - Number(!!a.pinned); if (pinnedOrder) return pinnedOrder; return new Date(b.updatedAt || b.createdAt || 0).getTime() - new Date(a.updatedAt || a.createdAt || 0).getTime(); }); } function render() { const notes = visibleNotes(); listEl.textContent = ''; const filtered = notes.length !== allNotes.length || !!(selectedCollection || selectedSource || selectedTag || selectedNoteType || searchText); statusEl.textContent = filtered ? `显示 ${notes.length} 条,共 ${allNotes.length} 条笔记` : `共 ${allNotes.length} 条笔记`; if (!notes.length) { const empty = document.createElement('div'); empty.className = 'notes-empty'; empty.textContent = allNotes.length ? '没有符合当前筛选条件的笔记' : '还没有笔记。可以直接新建,或在阅读器中选定位置后记录。'; listEl.appendChild(empty); return; } const collectionNames = new Map(collections.map((collection) => [ String(collection.id), String(collection.name || '未命名笔记本') ])); notes.forEach((note) => listEl.appendChild(renderNote(note, collectionNames))); } function renderNote(note, collectionNames) { const card = document.createElement('article'); card.className = 'note-card'; card.dataset.noteId = String(note.id); card.dataset.noteType = note.noteType || (note.canvasContent ? 'canvas' : 'reading'); if (note.pinned) card.classList.add('pinned'); const head = document.createElement('div'); head.className = 'note-card-head'; const titleBox = document.createElement('div'); titleBox.className = 'note-title-box'; const bookTitle = document.createElement('div'); bookTitle.className = 'note-book-title'; const book = note.bookSnapshot || note.book || {}; bookTitle.textContent = note.associated === false ? '未关联书籍' : (book.title || '未知书籍'); titleBox.appendChild(bookTitle); const authors = Array.isArray(book.authors) ? book.authors.map((author) => String(author || '').trim()).filter(Boolean) : []; if (authors.length) { const authorLine = document.createElement('div'); authorLine.className = 'note-book-authors'; authorLine.textContent = authors.join(', '); titleBox.appendChild(authorLine); } if (note.title && note.title !== bookTitle.textContent) { const title = document.createElement('div'); title.className = 'note-title'; title.textContent = note.title; titleBox.appendChild(title); } const badges = document.createElement('div'); badges.className = 'note-badges'; if (note.pinned) { const pinned = document.createElement('span'); pinned.className = 'note-badge pinned'; pinned.textContent = '置顶'; badges.appendChild(pinned); } const source = document.createElement('span'); source.className = 'note-badge source'; source.textContent = sourceLabel(note.source); const type = document.createElement('span'); const noteType = note.noteType || (note.canvasContent ? 'canvas' : 'reading'); type.className = `note-badge type ${noteType}`; type.textContent = noteType === 'canvas' ? '画布笔记' : '读书笔记'; badges.append(type, source); head.append(titleBox, badges); card.appendChild(head); if (note.quote) { const quote = document.createElement('blockquote'); quote.className = 'note-quote'; quote.textContent = note.quote; card.appendChild(quote); } if (note.text || note.richContent) { const text = document.createElement('div'); text.className = 'note-text'; window.RichNote.render(text, note.richContent, note.text); card.appendChild(text); } if (note.canvasContent && Array.isArray(note.canvasContent.pages)) { const canvas = document.createElement('div'); const firstPage = note.canvasContent.pages[0]; const template = firstPage?.background?.type === 'template' ? firstPage.background.template : 'pdf'; canvas.className = `note-canvas-summary note-canvas-preview template-${template}`; const pdfPages = note.canvasContent.pages.filter((page) => ( page.background && page.background.type === 'pdf' )).length; canvas.textContent = `自由画布 · ${note.canvasContent.pages.length} 页` + (pdfPages ? ` · ${pdfPages} 页 PDF 底版` : ''); card.appendChild(canvas); } if (note.context && note.context !== note.quote) { const context = document.createElement('div'); context.className = 'note-context'; context.textContent = note.context; card.appendChild(context); } const tags = noteTags(note); if (tags.length) { const tagRow = document.createElement('div'); tagRow.className = 'note-tags'; tags.forEach((tag) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'note-tag'; button.textContent = tag; button.onclick = () => { selectedTag = tag; renderTagFilters(); render(); }; tagRow.appendChild(button); }); card.appendChild(tagRow); } const footer = document.createElement('div'); footer.className = 'note-card-footer'; const meta = document.createElement('div'); meta.className = 'note-meta'; const collectionName = note.collectionId == null ? '未分类' : (collectionNames.get(String(note.collectionId)) || '未分类'); const date = noteDate(note.updatedAt || note.createdAt); meta.textContent = collectionName + (date ? ` · ${date}` : ''); const actions = document.createElement('div'); actions.className = 'note-actions'; // 独立窗口是单窗口多标签,这里的"已开"指这条笔记已占了一个标签 const windowOpen = openWindowIds.has(String(note.id)); // 已开标签时不再开模态:同一条笔记两处编辑,后保存者会整条覆盖前者 const editButton = actionButton(windowOpen ? '在窗口中编辑' : '编辑', 'edit'); editButton.onclick = () => (windowOpen ? openNoteWindow(note) : editNote(note)); const deleteButton = actionButton('删除', 'delete'); deleteButton.onclick = () => deleteNote(note); if (note.associated !== false) { const openButton = actionButton('打开原文', 'open'); openButton.onclick = () => openNote(note); actions.appendChild(openButton); } const windowButton = actionButton(windowOpen ? '切到窗口' : '独立窗口', 'window'); windowButton.onclick = () => openNoteWindow(note); actions.appendChild(windowButton); actions.append(editButton, deleteButton); footer.append(meta, actions); card.appendChild(footer); return card; } function actionButton(label, kind) { const button = document.createElement('button'); button.type = 'button'; button.className = `note-action ${kind}`; button.textContent = label; return button; } function noteDate(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } async function openNoteWindow(note) { let result; try { result = await window.api.notes.openWindow(note.entryId, note.id); } catch (error) { await confirmModal('无法打开', errorText(error, '打开笔记窗口失败')); return; } if (!result || !result.ok) { await confirmModal('无法打开', errorText(result && result.error, '打开笔记窗口失败')); } } async function openNote(note) { if (note.associated === false || !note.entryId) { await confirmModal('无法打开', '这条笔记缺少书籍定位信息。'); return; } let result; try { result = await window.api.reader.openAt( note.entryId, note.fileIndex, note.documentKey, note.locator ); } catch (error) { await confirmModal('无法打开', errorText(error, '打开阅读器失败')); return; } if (!result || !result.ok) { await confirmModal('无法打开', errorText(result && result.error, '打开阅读器失败')); } } async function chooseNewNoteType() { return openModal('选择笔记类型', `
`, () => document.querySelector('input[name="newNoteType"]:checked')?.value || false); } async function addGlobalNote() { const noteType = await chooseNewNoteType(); if (!noteType) return; let libraryResult; try { libraryResult = await window.api.library.list(); } catch (error) { await confirmModal('无法新建', errorText(error, '读取书库失败')); return; } if (!libraryResult || !libraryResult.ok) { await confirmModal('无法新建', errorText(libraryResult && libraryResult.error, '读取书库失败')); return; } const books = dataList(libraryResult, 'items'); const bookOptions = books.map((book) => ( `` )).join(''); const collectionOptions = collections.map((collection) => ( `` )).join(''); let editor; const isCanvas = noteType === 'canvas'; const pending = openModal(isCanvas ? '新建画布笔记' : '新建读书笔记', `
${isCanvas ? '
' : ''}
`, async () => { await editor.ready(); const richContent = editor.richContent(); const canvasContent = editor.canvasContent(); const text = editor.text().trim(); if (!editor.hasContent()) { $('newNoteError').textContent = '请输入笔记内容'; return false; } let response; try { const note = { noteType, title: $('newNoteTitle').value.trim(), ...(isCanvas ? { canvasContent } : { text, richContent }), source: 'manual', locator: null, collectionId: $('newNoteCollection').value || null, tags: $('newNoteTags').value .split(/[,,]/) .map((tag) => tag.trim()) .filter(Boolean), pinned: $('newNotePinned').checked }; const entryId = $('newNoteBook').value; response = entryId ? await window.api.reader.addNote(entryId, note) : await window.api.reader.addStandaloneNote(note); } catch (error) { $('newNoteError').textContent = errorText(error, '保存失败'); return false; } if (!response || !response.ok) { $('newNoteError').textContent = errorText(response && response.error, '保存失败'); return false; } return true; }); if (isCanvas) $('modal').classList.add('canvas-note-modal'); editor = window.MixedNote.mount($('newNoteRich'), null, null, { noteType, onError: (message) => { $('newNoteError').textContent = message; } }); const result = await pending; $('modal').classList.remove('canvas-note-modal'); editor.destroy(); if (!result) return; dirty = true; await refresh(true); } async function editNote(note) { const noteType = note.noteType || (note.canvasContent ? 'canvas' : 'reading'); const isCanvas = noteType === 'canvas'; const options = collections.map((collection) => { const selected = String(collection.id) === String(note.collectionId) ? ' selected' : ''; return ``; }).join(''); let editor; const pending = openModal(isCanvas ? '编辑画布笔记' : '编辑读书笔记', `
${isCanvas ? '
' : ''}
`, async () => { await editor.ready(); const richContent = editor.richContent(); const canvasContent = editor.canvasContent(); const text = editor.text().trim(); const patch = { noteType, title: $('noteEditTitle').value.trim(), ...(isCanvas ? { canvasContent } : { text, richContent }), collectionId: $('noteEditCollection').value || null, tags: $('noteEditTags').value.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean), pinned: $('noteEditPinned').checked }; try { const update = await window.api.reader.updateNote(note.entryId, note.id, patch); if (!update || !update.ok) { $('noteEditError').textContent = errorText(update && update.error, '保存失败'); return false; } return true; } catch (error) { $('noteEditError').textContent = errorText(error, '保存失败'); return false; } }); if (isCanvas) $('modal').classList.add('canvas-note-modal'); editor = window.MixedNote.mount( $('noteEditRich'), isCanvas ? null : (note.richContent || window.RichNote.fromText(note.text)), isCanvas ? (note.canvasContent || null) : null, { noteType, onError: (message) => { $('noteEditError').textContent = message; } } ); const result = await pending; $('modal').classList.remove('canvas-note-modal'); editor.destroy(); if (!result) return; dirty = true; await refresh(true); } async function deleteNote(note) { const ok = await confirmModal('删除笔记', '确定删除这条笔记吗?此操作无法撤销。'); if (!ok) return; let result; try { result = await window.api.reader.removeNote(note.entryId, note.id); } catch (error) { await confirmModal('删除失败', errorText(error, '未知错误')); return; } if (!result || !result.ok) { await confirmModal('删除失败', errorText(result && result.error, '未知错误')); return; } dirty = true; await refresh(true); } async function addCollection() { const result = await openModal('新建笔记本', `

笔记本为单层分类,不支持嵌套。

`, async () => { const name = $('collectionName').value.trim(); if (!name) { $('collectionError').textContent = '请输入笔记本名称'; return false; } try { const created = await window.api.reader.addCollection({ name }); if (!created || !created.ok) { $('collectionError').textContent = errorText(created && created.error, '创建失败'); return false; } return created.data || true; } catch (error) { $('collectionError').textContent = errorText(error, '创建失败'); return false; } }); if (!result) return; if (result.id != null) selectedCollection = String(result.id); dirty = true; await refresh(true); } async function renameCollection(collection) { const result = await openModal('重命名笔记本', `
`, async () => { const name = $('collectionName').value.trim(); if (!name) { $('collectionError').textContent = '请输入笔记本名称'; return false; } try { const updated = await window.api.reader.updateCollection(collection.id, { name }); if (!updated || !updated.ok) { $('collectionError').textContent = errorText(updated && updated.error, '重命名失败'); return false; } return true; } catch (error) { $('collectionError').textContent = errorText(error, '重命名失败'); return false; } }); if (!result) return; dirty = true; await refresh(true); } async function deleteCollection(collection) { const name = collection.name || '未命名笔记本'; const ok = await confirmModal( '删除笔记本', `确定删除「${name}」吗?其中的笔记不会被删除,将移至「未分类」。` ); if (!ok) return; let result; try { result = await window.api.reader.removeCollection(collection.id); } catch (error) { await confirmModal('删除失败', errorText(error, '未知错误')); return; } if (!result || !result.ok) { await confirmModal('删除失败', errorText(result && result.error, '未知错误')); return; } if (selectedCollection === String(collection.id)) selectedCollection = UNCATEGORIZED; dirty = true; await refresh(true); } return { init, refresh, markDirty }; })(); window.Notes = Notes;