diff --git a/Plugin/DailyNotePanel/frontend/index.html b/Plugin/DailyNotePanel/frontend/index.html index 6aec70905..78472cebf 100644 --- a/Plugin/DailyNotePanel/frontend/index.html +++ b/Plugin/DailyNotePanel/frontend/index.html @@ -16,6 +16,25 @@

日记本

+
+
+ 工作台 +
+
+ 📋 +
@@ -43,8 +62,11 @@

日记本

/>
- +
@@ -73,18 +95,50 @@

日记本

- -
- -
- -
@@ -180,8 +234,45 @@ + + + + + + + \ No newline at end of file diff --git a/Plugin/DailyNotePanel/frontend/manifest.json b/Plugin/DailyNotePanel/frontend/manifest.json index b746a2b40..8ced56490 100644 --- a/Plugin/DailyNotePanel/frontend/manifest.json +++ b/Plugin/DailyNotePanel/frontend/manifest.json @@ -1,7 +1,7 @@ { "name": "DailyNotePanel", "short_name": "DailyNote", - "version": "1.4.1", + "version": "2.0.0", "author": "B3000Kcn & DBL1F7E5", "start_url": "/AdminPanel/DailyNotePanel/", "display": "standalone", diff --git a/Plugin/DailyNotePanel/frontend/script.js b/Plugin/DailyNotePanel/frontend/script.js index 59d574f66..f0207c520 100644 --- a/Plugin/DailyNotePanel/frontend/script.js +++ b/Plugin/DailyNotePanel/frontend/script.js @@ -49,7 +49,8 @@ const topBarSettings = document.getElementById('top-bar-settings'); const searchInput = document.getElementById('search-input'); - const bulkToggleButton = document.getElementById('bulk-toggle-button'); + const bulkMoveButton = document.getElementById('bulk-move-button'); + const bulkDeleteButton = document.getElementById('bulk-delete-button'); const cardsView = document.getElementById('cards-view'); const editorView = document.getElementById('editor-view'); @@ -60,6 +61,17 @@ const prevPageBtn = document.getElementById('prev-page'); const nextPageBtn = document.getElementById('next-page'); const pageInfoSpan = document.getElementById('page-info'); + const workbenchEntry = document.getElementById('workbench-entry'); + const workbenchMiniEntry = document.getElementById('workbench-mini-entry'); + const workbenchEditorPane = document.getElementById('workbench-editor-pane'); + const workbenchPreviewPane = document.getElementById('workbench-preview-pane'); + const workbenchFilenameSpan = document.getElementById('workbench-filename'); + const workbenchDirtyIndicator = document.getElementById('workbench-dirty-indicator'); + const workbenchSaveButton = document.getElementById('workbench-save-button'); + const workbenchEditorEmpty = document.getElementById('workbench-editor-empty'); + const workbenchEditorTextarea = document.getElementById('workbench-editor-textarea'); + const workbenchPreviewEmpty = document.getElementById('workbench-preview-empty'); + const workbenchPreview = document.getElementById('workbench-preview'); const deleteModalBackdrop = document.getElementById('delete-modal-backdrop'); const deleteCountSpan = document.getElementById('delete-count'); @@ -67,6 +79,18 @@ const deleteCancelBtn = document.getElementById('delete-cancel'); const deleteConfirmBtn = document.getElementById('delete-confirm'); + const moveModalBackdrop = document.getElementById('move-modal-backdrop'); + const moveCountSpan = document.getElementById('move-count'); + const moveListContainer = document.getElementById('move-list'); + const moveTargetSelect = document.getElementById('move-target-select'); + const moveTargetHint = document.getElementById('move-target-hint'); + const moveCancelBtn = document.getElementById('move-cancel'); + const moveConfirmBtn = document.getElementById('move-confirm'); + const unsavedModalBackdrop = document.getElementById('unsaved-modal-backdrop'); + const unsavedModalMessage = document.getElementById('unsaved-modal-message'); + const unsavedCancelBtn = document.getElementById('unsaved-cancel'); + const unsavedConfirmBtn = document.getElementById('unsaved-confirm'); + const backToCardsBtn = document.getElementById('back-to-cards'); const editorFilenameSpan = document.getElementById('editor-filename'); const editorModeToggle = document.getElementById('editor-mode-toggle'); @@ -96,6 +120,7 @@ let notes = []; // 当前「源列表」(可能来自单本缓存或日记流聚合) let filteredNotes = []; // 排序 + 过滤后的列表 let bulkMode = false; // 批量选择模式 + let bulkAction = null; // null | move | delete let selectedSet = new Set(); // `folder/name` 形式 let currentPage = 1; // 简单分页 let editorState = { @@ -103,6 +128,16 @@ file: null, mode: 'edit' // edit | preview }; + let workbenchMode = false; + let workbenchState = { + folder: null, + file: null, + noteId: null + }; + let workbenchDirty = false; + let workbenchBaselineContent = ''; + let workbenchScrollSyncLock = false; + let workbenchPreviewRaf = null; // 全局缓存:每个日记本自己的 notes 列表 // key: folderName, value: notes[] @@ -118,14 +153,25 @@ // 高亮定时器:key = `${folderName}/${note.name}`, value = { toYellow, clearAll } let highlightTimers = new Map(); - // 删除确认弹窗当前要删除的列表缓存 + // 操作弹窗状态 let pendingDeleteFiles = []; + let pendingMoveFiles = []; + let pendingMoveTargetFolder = null; + let pendingUnsavedResolver = null; + + // 卡片区底部轻量反馈 + let cardsFeedbackMessage = ''; + let cardsFeedbackTimer = null; + + // Markdown 预览渲染器配置状态 + let markdownRendererConfigured = false; // ------- 工具函数 ------- - async function apiGet(path) { + async function apiGet(path, options) { const res = await fetch(API_BASE + path, { - headers: { 'Accept': 'application/json' } + headers: { 'Accept': 'application/json' }, + ...(options || {}) }); if (!res.ok) throw new Error(res.status + ' ' + res.statusText); return res.json(); @@ -141,52 +187,337 @@ return res.json(); } - // 极简 Markdown 渲染(不引入外部依赖,够用版) - function renderMarkdown(text) { - if (!text) return ''; - let html = text; - - // 转义基础 HTML - html = html - .replace(/&/g, '&') - .replace(//g, '>'); - - // 代码块 ```...``` - html = html.replace(/```([\s\S]*?)```/g, function (_, code) { - return '
' + code.trim().replace(/\n/g, '
') + '
'; + function syncBulkActionButtons() { + if (bulkMoveButton) { + bulkMoveButton.classList.toggle('move-active', bulkMode && bulkAction === 'move'); + } + if (bulkDeleteButton) { + bulkDeleteButton.classList.toggle('danger-active', bulkMode && bulkAction === 'delete'); + } + } + + function collectFilesFromSelectedSet() { + return Array.from(selectedSet).map(id => { + const separatorIndex = id.indexOf('/'); + return { + folder: separatorIndex >= 0 ? id.slice(0, separatorIndex) : '', + file: separatorIndex >= 0 ? id.slice(separatorIndex + 1) : id + }; + }); + } + + function buildOperationSummary(actionLabel, successCount, errorCount) { + if (errorCount > 0) { + return `已${actionLabel} ${successCount} 条,失败 ${errorCount} 条`; + } + return `已${actionLabel} ${successCount} 条`; + } + + function extractStreamCardMaidFromPreview(previewText) { + const text = String(previewText || '') + .replace(/^\uFEFF/, '') + .trimStart(); + + if (!text.startsWith('[')) return null; + + const separatorIndex = text.indexOf('] - '); + if (separatorIndex === -1) return null; + + const tail = text.slice(separatorIndex + 4).trimStart(); + if (!tail) return null; + + const firstSpaceIndex = tail.indexOf(' '); + const maidName = firstSpaceIndex === -1 ? tail : tail.slice(0, firstSpaceIndex); + + return maidName.trim() || null; + } + + function setCardsFeedback(message, durationMs) { + cardsFeedbackMessage = message || ''; + if (cardsFeedbackTimer) { + clearTimeout(cardsFeedbackTimer); + cardsFeedbackTimer = null; + } + renderCardsStatus(); + if (cardsFeedbackMessage) { + cardsFeedbackTimer = setTimeout(() => { + cardsFeedbackMessage = ''; + cardsFeedbackTimer = null; + renderCardsStatus(); + }, typeof durationMs === 'number' ? durationMs : 4000); + } + } + + function getSourceFoldersFromFiles(files) { + return new Set((files || []).map(item => item.folder).filter(Boolean)); + } + + function normalizeFolderCollection(folders) { + if (!folders) return []; + const arr = Array.isArray(folders) ? folders : Array.from(folders); + return Array.from(new Set(arr.filter(Boolean))); + } + + function getMoveTargetCandidates(files) { + const sourceFolders = getSourceFoldersFromFiles(files); + return getVisibleNotebooks().filter(nb => !sourceFolders.has(nb.name)); + } + + function updateNotebookLatestMtimeFromCache(notebookName) { + const list = notebookCache.get(notebookName) || []; + const latest = list.reduce( + (max, note) => ((note && note.mtime) > max ? note.mtime : max), + 0 + ); + notebookLatestMtime.set(notebookName, latest); + } + + function getErroredNoteIdSet(errors) { + const erroredIds = new Set(); + (errors || []).forEach(item => { + if (!item) return; + if (typeof item === 'string') { + erroredIds.add(item); + return; + } + if (item.note) { + erroredIds.add(String(item.note)); + } + }); + return erroredIds; + } + + function applyLocalMovePatch(files, targetFolder) { + if (!targetFolder) return 0; + + const targetList = (notebookCache.get(targetFolder) || []).slice(); + let patchedCount = 0; + + (files || []).forEach(item => { + if (!item || !item.folder || !item.file) return; + + const sourceList = (notebookCache.get(item.folder) || []).slice(); + const sourceIndex = sourceList.findIndex(note => note && note.name === item.file); + if (sourceIndex === -1) return; + + const [movedNote] = sourceList.splice(sourceIndex, 1); + notebookCache.set(item.folder, sourceList); + updateNotebookLatestMtimeFromCache(item.folder); + + const patchedNote = { + ...movedNote, + folderName: targetFolder + }; + + const targetIndex = targetList.findIndex(note => note && note.name === item.file); + if (targetIndex >= 0) { + targetList[targetIndex] = patchedNote; + } else { + targetList.push(patchedNote); + } + + patchedCount += 1; + }); + + notebookCache.set(targetFolder, targetList); + updateNotebookLatestMtimeFromCache(targetFolder); + + return patchedCount; + } + + function renderModalList(container, files) { + if (!container) return; + container.innerHTML = ''; + (files || []).forEach(item => { + const div = document.createElement('div'); + div.className = 'modal-list-item'; + div.textContent = `${item.folder}/${item.file}`; + container.appendChild(div); + }); + } + + function escapeHtml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function renderMarkdownFallback(text) { + const escaped = escapeHtml(String(text || '')).replace(/\r\n?/g, '\n'); + return `
${escaped}
`; + } + + function isSafeMarkdownUrl(rawUrl, type) { + if (typeof rawUrl !== 'string') return false; + + const url = rawUrl.trim(); + if (!url) return false; + + if (url.startsWith('#')) return true; + if (url.startsWith('/')) return true; + if (url.startsWith('./') || url.startsWith('../')) return true; + + const lowerUrl = url.toLowerCase(); + if (type === 'src' && lowerUrl.startsWith('data:image/')) { + return true; + } + + try { + const parsed = new URL(url, window.location.origin); + const protocol = parsed.protocol.toLowerCase(); + + if (type === 'href') { + return ( + protocol === 'http:' || + protocol === 'https:' || + protocol === 'mailto:' || + protocol === 'tel:' + ); + } + + if (type === 'src') { + return ( + protocol === 'http:' || + protocol === 'https:' || + protocol === 'blob:' + ); + } + } catch (e) { + return false; + } + + return false; + } + + function sanitizeMarkdownElement(root) { + const allowedTags = new Set([ + 'a', 'blockquote', 'br', 'code', 'del', 'em', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', + 'img', 'input', 'li', 'ol', 'p', 'pre', + 'strong', 'table', 'tbody', 'td', 'th', + 'thead', 'tr', 'ul' + ]); + + const globalAttrs = new Set(['class']); + const perTagAttrs = { + a: new Set(['href', 'title']), + code: new Set(['class']), + img: new Set(['src', 'alt', 'title']), + input: new Set(['type', 'checked', 'disabled']), + ol: new Set(['start']), + td: new Set(['align']), + th: new Set(['align']) + }; + + const nodes = Array.from(root.querySelectorAll('*')); + nodes.forEach(node => { + if (!node.parentNode) return; + + const tagName = node.tagName.toLowerCase(); + if (!allowedTags.has(tagName)) { + node.replaceWith(document.createTextNode(node.outerHTML)); + return; + } + + const allowedForTag = perTagAttrs[tagName] || new Set(); + Array.from(node.attributes).forEach(attr => { + const attrName = attr.name.toLowerCase(); + + if (attrName.startsWith('on')) { + node.removeAttribute(attr.name); + return; + } + + if (!globalAttrs.has(attrName) && !allowedForTag.has(attrName)) { + node.removeAttribute(attr.name); + } + }); + + if (tagName === 'a') { + const href = node.getAttribute('href') || ''; + if (!isSafeMarkdownUrl(href, 'href')) { + node.replaceWith(document.createTextNode(node.outerHTML)); + return; + } + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer nofollow'); + } + + if (tagName === 'img') { + const src = node.getAttribute('src') || ''; + if (!isSafeMarkdownUrl(src, 'src')) { + node.replaceWith(document.createTextNode(node.outerHTML)); + return; + } + node.setAttribute('loading', 'lazy'); + node.setAttribute('decoding', 'async'); + node.setAttribute('referrerpolicy', 'no-referrer'); + } + + if (tagName === 'input') { + const type = (node.getAttribute('type') || '').toLowerCase(); + if (type !== 'checkbox') { + node.replaceWith(document.createTextNode(node.outerHTML)); + return; + } + node.setAttribute('disabled', ''); + node.setAttribute('tabindex', '-1'); + if (node.hasAttribute('checked')) { + node.setAttribute('checked', ''); + } + } }); - // 行内代码 `code` - html = html.replace(/`([^`]+)`/g, '$1'); - - // 标题行 # / ## / ### - html = html.replace(/^###\s+(.*)$/gm, '

$1

'); - html = html.replace(/^##\s+(.*)$/gm, '

$1

'); - html = html.replace(/^#\s+(.*)$/gm, '

$1

'); - - // 无序列表行 - / * 开头 - html = html.replace(/^(?:\s*[-*]\s+.+\n?)+/gm, function (block) { - const items = block - .trim() - .split(/\n/) - .map(line => line.replace(/^\s*[-*]\s+/, '').trim()) - .filter(Boolean) - .map(item => '
  • ' + item + '
  • ') - .join(''); - return ''; + Array.from(root.querySelectorAll('table')).forEach(table => { + if (table.parentElement && table.parentElement.classList.contains('markdown-table-scroll')) { + return; + } + const wrapper = document.createElement('div'); + wrapper.className = 'markdown-table-scroll'; + table.parentNode.insertBefore(wrapper, table); + wrapper.appendChild(table); }); + } + + function sanitizeMarkdownHtml(html) { + const template = document.createElement('template'); + template.innerHTML = html; + sanitizeMarkdownElement(template.content); + return template.innerHTML; + } + + function configureMarkdownRenderer() { + if (markdownRendererConfigured) return; + if (typeof marked === 'undefined' || !marked || typeof marked.setOptions !== 'function') { + return; + } - // 段落:简单按双换行切分 - html = html - .split(/\n{2,}/) - .map(chunk => { - if (/^/.test(chunk) || /^