From f3e67fc0aa7d7f41b4baf511f596fbae8962274e Mon Sep 17 00:00:00 2001 From: Sammsai Date: Thu, 20 Aug 2026 20:41:12 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=A8=A1=E5=9D=97=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=8E=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/notes/NoteExportMenu.tsx | 179 +++++++++++ components/notes/NoteOutline.tsx | 85 ++++++ components/notes/NotesList.tsx | 331 ++++++++++++++++++++ components/notes/NotesManager.tsx | 265 +++++++++++++++-- components/notes/NotesSidebar.tsx | 447 ++++++++++++++++++++++++++++ domain/models/connection.ts | 1 + domain/notes.test.ts | 71 +++++ domain/notes.ts | 150 ++++++++++ 8 files changed, 1501 insertions(+), 28 deletions(-) create mode 100644 components/notes/NoteExportMenu.tsx create mode 100644 components/notes/NoteOutline.tsx create mode 100644 components/notes/NotesList.tsx create mode 100644 components/notes/NotesSidebar.tsx diff --git a/components/notes/NoteExportMenu.tsx b/components/notes/NoteExportMenu.tsx new file mode 100644 index 000000000..62dfda7bd --- /dev/null +++ b/components/notes/NoteExportMenu.tsx @@ -0,0 +1,179 @@ +import { + Copy, + Download, + FileCode, + FileText, + Share2, +} from "lucide-react"; +import React, { useState, useRef, useEffect } from "react"; +import { useI18n } from "../../application/i18n/I18nProvider"; +import { + buildVaultNoteMarkdownExportFiles, + sanitizeNoteExportFileNamePart, + type VaultNote, +} from "../../domain/notes"; +import { copyToClipboard } from "../keychain/utils"; +import { toast } from "../ui/toast"; +import { buildTextFilesZipBlob } from "../../lib/textZip"; + +export interface NoteExportMenuProps { + note: VaultNote | null; + allNotes: VaultNote[]; + className?: string; +} + +export const NoteExportMenu: React.FC = ({ + note, + allNotes, + className = "", +}) => { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + window.addEventListener("mousedown", handleClickOutside); + return () => window.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + const handleExportSingleMarkdown = () => { + if (!note) return; + const fileName = `${sanitizeNoteExportFileNamePart(note.title, "note")}.md`; + const blob = new Blob([note.content], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + toast.success(t("notes.exportSuccess") || "已导出 Markdown 文件"); + setOpen(false); + }; + + const handleExportSingleHtml = () => { + if (!note) return; + const title = note.title || "Untitled Note"; + const escapedTitle = title.replace(//g, ">"); + const htmlContent = ` + + + + ${escapedTitle} + + + +

${escapedTitle}

+
${note.content.replace(//g, ">")}
+ +`; + const fileName = `${sanitizeNoteExportFileNamePart(note.title, "note")}.html`; + const blob = new Blob([htmlContent], { type: "text/html;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + toast.success(t("notes.exportSuccess") || "已导出 HTML 文件"); + setOpen(false); + }; + + const handleCopyMarkdown = async () => { + if (!note) return; + const ok = await copyToClipboard(note.content); + if (ok) { + toast.success(t("common.copied") || "已复制到剪贴板"); + } + setOpen(false); + }; + + const handleExportAllZip = () => { + if (!allNotes.length) return; + const files = buildVaultNoteMarkdownExportFiles(allNotes, { type: "all" }); + const zipBlob = buildTextFilesZipBlob(files); + const url = URL.createObjectURL(zipBlob); + const a = document.createElement("a"); + a.href = url; + a.download = `netcatty-notes-backup-${new Date().toISOString().slice(0, 10)}.zip`; + a.click(); + URL.revokeObjectURL(url); + toast.success(t("notes.exportAllSuccess") || "已导出全部笔记备份 ZIP"); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+ {note && ( + <> +
+ 当前笔记 +
+ + + +
+ + )} + +
+ 全部笔记 +
+ +
+ )} +
+ ); +}; diff --git a/components/notes/NoteOutline.tsx b/components/notes/NoteOutline.tsx new file mode 100644 index 000000000..710f6f222 --- /dev/null +++ b/components/notes/NoteOutline.tsx @@ -0,0 +1,85 @@ +import { Hash, ListTree, X } from "lucide-react"; +import React, { useMemo } from "react"; +import { extractNoteHeadings, type NoteHeadingItem } from "../../domain/notes"; +import { useI18n } from "../../application/i18n/I18nProvider"; + +export interface NoteOutlineProps { + content: string; + onSelectHeading?: (heading: NoteHeadingItem) => void; + onClose?: () => void; + className?: string; +} + +export const NoteOutline: React.FC = ({ + content, + onSelectHeading, + onClose, + className = "", +}) => { + const { t } = useI18n(); + const headings = useMemo(() => extractNoteHeadings(content), [content]); + + const getIndentClass = (level: number) => { + switch (level) { + case 1: + return "pl-2 font-semibold text-foreground"; + case 2: + return "pl-5 text-foreground/90"; + case 3: + return "pl-8 text-muted-foreground"; + case 4: + default: + return "pl-11 text-muted-foreground/80 text-xs"; + } + }; + + return ( +
+
+
+ + 大纲目录 ({headings.length}) +
+ {onClose && ( + + )} +
+ +
+ {headings.length === 0 ? ( +
+ +

暂无标题大纲

+

+ 在 Markdown 中使用 #、## 编写各级标题 +

+
+ ) : ( + headings.map((item) => ( + + )) + )} +
+
+ ); +}; diff --git a/components/notes/NotesList.tsx b/components/notes/NotesList.tsx new file mode 100644 index 000000000..93b945aac --- /dev/null +++ b/components/notes/NotesList.tsx @@ -0,0 +1,331 @@ +import { + Copy, + Folder, + Hash, + MoreVertical, + Pin, + PinOff, + Plus, + Search, + Trash2, + X, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useI18n } from "../../application/i18n/I18nProvider"; +import { + extractNoteSnippet, + filterAndSortVaultNotes, + getNoteGroupLeafName, + type NoteFilterMode, + type NoteSortOption, + type VaultNote, +} from "../../domain/notes"; +import type { Host } from "../../types"; + +export interface NotesListProps { + notes: VaultNote[]; + selectedNoteId: string | null; + selectedGroup: string | null; + selectedTag: string | null; + filterMode: NoteFilterMode; + hosts?: Host[]; + onSelectNote: (noteId: string) => void; + onCreateNote: () => void; + onTogglePinNote: (noteId: string) => void; + onDuplicateNote: (note: VaultNote) => void; + onDeleteNote: (noteId: string) => void; + className?: string; +} + +function formatNoteRelativeTime(timestamp: number): string { + if (!timestamp || !Number.isFinite(timestamp)) return ""; + const diff = Date.now() - timestamp; + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + + if (diff < minute) return "刚刚"; + if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`; + if (diff < day) { + const d = new Date(timestamp); + return `${d.getHours().toString().padStart(2, "0")}:${d + .getMinutes() + .toString() + .padStart(2, "0")}`; + } + if (diff < 2 * day) return "昨天"; + if (diff < 365 * day) { + const d = new Date(timestamp); + return `${d.getMonth() + 1}月${d.getDate()}日`; + } + const d = new Date(timestamp); + return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; +} + +export const NotesList: React.FC = ({ + notes, + selectedNoteId, + selectedGroup, + selectedTag, + filterMode, + hosts = [], + onSelectNote, + onCreateNote, + onTogglePinNote, + onDuplicateNote, + onDeleteNote, + className = "", +}) => { + const { t } = useI18n(); + const [searchQuery, setSearchQuery] = useState(""); + const [sortOption, setSortOption] = useState("updatedDesc"); + const [activeMenuNoteId, setActiveMenuNoteId] = useState(null); + + // Compute title for the list header + const headerTitle = useMemo(() => { + if (selectedTag) return `#${selectedTag}`; + if (selectedGroup) return getNoteGroupLeafName(selectedGroup); + if (filterMode === "pinned") return t("notes.pinned") || "已置顶笔记"; + if (filterMode === "recent") return t("notes.recent") || "最近修改"; + if (filterMode === "uncategorized") return t("notes.uncategorized") || "未分类笔记"; + return t("notes.allNotes") || "全部笔记"; + }, [selectedTag, selectedGroup, filterMode, t]); + + // Filter and sort notes + const displayedNotes = useMemo(() => { + return filterAndSortVaultNotes(notes, { + search: searchQuery, + group: selectedGroup, + tag: selectedTag, + filterMode, + sort: sortOption, + hosts, + }); + }, [notes, searchQuery, selectedGroup, selectedTag, filterMode, sortOption, hosts]); + + return ( +
+ {/* List Header */} +
+
+
+

+ {headerTitle} +

+ + {displayedNotes.length} + +
+ +
+ + + +
+
+ + {/* Search input */} +
+ + setSearchQuery(e.target.value)} + placeholder={t("notes.searchPlaceholder") || "搜索笔记标题、内容、标签..."} + className="w-full bg-muted/50 focus:bg-background border border-border focus:border-primary/60 rounded-md pl-7 pr-7 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/70 outline-none transition-all" + /> + {searchQuery && ( + + )} +
+
+ + {/* Cards List */} +
+ {displayedNotes.length === 0 ? ( +
+

暂无匹配笔记

+ {searchQuery ? ( +

+ 可尝试更改搜索关键词或清除搜索条件 +

+ ) : ( + + )} +
+ ) : ( + displayedNotes.map((note) => { + const isSelected = selectedNoteId === note.id; + const snippet = extractNoteSnippet(note.content, 90); + const title = note.title.trim() || t("notes.untitled") || "无标题笔记"; + const isMenuOpen = activeMenuNoteId === note.id; + + return ( +
onSelectNote(note.id)} + > + {/* Title & Pin Indicator */} +
+
+ {note.isPinned && ( + + )} + + {title} + +
+ + {/* Actions Dropdown Trigger on hover */} +
+ + + {isMenuOpen && ( +
e.stopPropagation()} + > + + +
+ +
+ )} +
+
+ + {/* Snippet Preview */} + {snippet && ( +

+ {snippet} +

+ )} + + {/* Footer Metadata: Date, Folder, Tags */} +
+ + {formatNoteRelativeTime(note.updatedAt)} + + +
+ {note.group && ( + + + {getNoteGroupLeafName(note.group)} + + )} + {note.tags && note.tags.length > 0 && ( + + + {note.tags[0]} + {note.tags.length > 1 && ( + +{note.tags.length - 1} + )} + + )} +
+
+
+ ); + }) + )} +
+
+ ); +}; diff --git a/components/notes/NotesManager.tsx b/components/notes/NotesManager.tsx index 2dddc5c8a..c7ba4351f 100644 --- a/components/notes/NotesManager.tsx +++ b/components/notes/NotesManager.tsx @@ -7,11 +7,15 @@ import { Folder, FolderPlus, Glasses, + Hash, + ListTree, MoreHorizontal, Minimize2, PencilLine, + Pin, Plus, Search, + Trash2, Upload, X, } from "lucide-react"; @@ -20,6 +24,8 @@ import { useI18n } from "../../application/i18n/I18nProvider"; import { useApplicationBackend } from "../../application/state/useApplicationBackend"; import { useStoredNumber } from "../../application/state/useStoredNumber"; import { useStoredString } from "../../application/state/useStoredString"; +import { NoteOutline } from "./NoteOutline"; +import { NoteExportMenu } from "./NoteExportMenu"; import { ancestorNoteGroupPaths, buildVaultNoteMarkdownExportFiles, @@ -388,6 +394,9 @@ export const NotesManager: React.FC = ({ id: string; name: string; } | null>(null); + const [showOutline, setShowOutline] = useState(false); + const [tagInputOpen, setTagInputOpen] = useState(false); + const [newTagText, setNewTagText] = useState(""); const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber( STORAGE_KEY_VAULT_NOTES_TREE_WIDTH, NOTES_TREE_DEFAULT_WIDTH, @@ -418,6 +427,34 @@ export const NotesManager: React.FC = ({ return cleaned; }, [onUpdateNotes]); + const togglePinNoteById = useCallback((noteId: string) => { + commitNotes(sortedNotesRef.current.map((n) => { + if (n.id !== noteId) return n; + const isPinned = !n.isPinned; + return { ...n, isPinned: isPinned ? true : undefined, updatedAt: Date.now() }; + })); + }, [commitNotes]); + + const addTagToNote = useCallback((noteId: string, tag: string) => { + const clean = tag.trim(); + if (!clean) return; + commitNotes(sortedNotesRef.current.map((n) => { + if (n.id !== noteId) return n; + const existing = n.tags ?? []; + if (existing.includes(clean)) return n; + return { ...n, tags: [...existing, clean], updatedAt: Date.now() }; + })); + }, [commitNotes]); + + const removeTagFromNote = useCallback((noteId: string, tagToRemove: string) => { + commitNotes(sortedNotesRef.current.map((n) => { + if (n.id !== noteId) return n; + const existing = n.tags ?? []; + const nextTags = existing.filter((t) => t !== tagToRemove); + return { ...n, tags: nextTags.length ? nextTags : undefined, updatedAt: Date.now() }; + })); + }, [commitNotes]); + const NOTE_DRAFT_DEBOUNCE_MS = 300; const draftNoteIdRef = useRef(null); const draftTitleRef = useRef(null); @@ -1124,6 +1161,10 @@ export const NotesManager: React.FC = ({ const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context", closeMenu?: () => void) => { const actions = [ + { + label: note.isPinned ? "取消置顶" : "置顶笔记", + action: () => togglePinNoteById(note.id), + }, { label: t("common.rename"), action: () => setEditingNoteId(note.id), @@ -1742,7 +1783,7 @@ export const NotesManager: React.FC = ({ > {selectedNoteView ? ( <> -
+
= ({ onBlur={() => flushNoteDraft()} />
+ + {/* Pin Toggle */} + + + + + + {selectedNoteView.isPinned ? "取消置顶" : "置顶笔记"} + + + + {/* Outline Toggle */} + + + + + 大纲目录 + + + {/* Export Menu */} + {renderNoteExportButton(selectedNoteView)} {renderNoteModeToggle()} + + {/* Delete Note */} + + + + + {t("action.delete")} + +
+ + {/* Tags & Folder Breadcrumb Header */} +
+ {selectedNoteView.group && ( + + + {selectedNoteView.group} + + )} + + {selectedNoteView.tags?.map((tag) => ( + + + {tag} + + + ))} + + {tagInputOpen ? ( +
+ + setNewTagText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (newTagText.trim()) { + addTagToNote(selectedNoteView.id, newTagText.trim()); + setNewTagText(""); + setTagInputOpen(false); + } + } else if (e.key === "Escape") { + setTagInputOpen(false); + setNewTagText(""); + } + }} + onBlur={() => { + if (newTagText.trim()) { + addTagToNote(selectedNoteView.id, newTagText.trim()); + } + setTagInputOpen(false); + setNewTagText(""); + }} + placeholder="标签名..." + className="w-20 bg-transparent text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60" + /> +
+ ) : ( + + )} +
+ +
+ +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + flushNoteDraft(); + } + }} + > + + }> + saveNoteContentDraft(selectedNoteView.id, content)} + hosts={hosts} + onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)} + onOpenExternalLink={openExternal} + /> + + +
+
+ + {showOutline && ( +
+ setShowOutline(false)} + /> +
+ )}
- -
{ - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - flushNoteDraft(); - } - }} - > - - }> - saveNoteContentDraft(selectedNoteView.id, content)} - hosts={hosts} - onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)} - onOpenExternalLink={openExternal} - /> - - -
-
) : (
@@ -1833,7 +2018,7 @@ export const NotesManager: React.FC = ({
-
+
= ({ onBlur={() => flushNoteDraft()} />
+ + {/* Pin Toggle */} + + + + + + {overlayNoteView.isPinned ? "取消置顶" : "置顶笔记"} + + + + {renderNoteExportButton(overlayNoteView)} {renderNoteModeToggle()}
diff --git a/components/notes/NotesSidebar.tsx b/components/notes/NotesSidebar.tsx new file mode 100644 index 000000000..ad10c6053 --- /dev/null +++ b/components/notes/NotesSidebar.tsx @@ -0,0 +1,447 @@ +import { + ChevronDown, + ChevronRight, + Clock, + Edit2, + FileQuestion, + FileText, + Folder, + FolderOpen, + FolderPlus, + Hash, + Pin, + Plus, + Tag, + Trash2, + Upload, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useI18n } from "../../application/i18n/I18nProvider"; +import { + ancestorNoteGroupPaths, + cleanNoteGroupPath, + extractAllNoteTags, + getNoteGroupLeafName, + getNoteGroupParentPath, + isNoteGroupInside, + joinNoteGroupPath, + type NoteFilterMode, + type VaultNote, +} from "../../domain/notes"; + +export interface NotesSidebarProps { + notes: VaultNote[]; + noteGroups: string[]; + selectedGroup: string | null; + selectedTag: string | null; + filterMode: NoteFilterMode; + onSelectFilterMode: (mode: NoteFilterMode) => void; + onSelectGroup: (group: string | null) => void; + onSelectTag: (tag: string | null) => void; + onCreateGroup: (parentGroup?: string | null) => void; + onRenameGroup: (group: string) => void; + onDeleteGroup: (group: string) => void; + onImportMarkdown?: () => void; + className?: string; +} + +interface GroupTreeItem { + path: string; + name: string; + depth: number; + hasChildren: boolean; + directCount: number; + totalCount: number; +} + +export const NotesSidebar: React.FC = ({ + notes, + noteGroups, + selectedGroup, + selectedTag, + filterMode, + onSelectFilterMode, + onSelectGroup, + onSelectTag, + onCreateGroup, + onRenameGroup, + onDeleteGroup, + onImportMarkdown, + className = "", +}) => { + const { t } = useI18n(); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + + const toggleFolder = (path: string, e: React.MouseEvent) => { + e.stopPropagation(); + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + // Compute counts for quick filter views + const counts = useMemo(() => { + const pinned = notes.filter((n) => n.isPinned).length; + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const recent = notes.filter((n) => n.updatedAt >= sevenDaysAgo).length; + const uncategorized = notes.filter((n) => !n.group).length; + return { + all: notes.length, + pinned, + recent, + uncategorized, + }; + }, [notes]); + + // Compute tags + const allTags = useMemo(() => extractAllNoteTags(notes), [notes]); + + // Build sorted folder hierarchy + const folderTree = useMemo(() => { + const allGroupPaths = new Set(); + for (const g of noteGroups) { + if (g.trim()) { + const clean = cleanNoteGroupPath(g); + ancestorNoteGroupPaths(clean).forEach((p) => allGroupPaths.add(p)); + } + } + for (const note of notes) { + if (note.group) { + ancestorNoteGroupPaths(cleanNoteGroupPath(note.group)).forEach((p) => + allGroupPaths.add(p), + ); + } + } + + const sortedPaths = Array.from(allGroupPaths).sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); + + const items: GroupTreeItem[] = []; + for (const path of sortedPaths) { + const parts = path.split("/"); + const depth = parts.length - 1; + const name = getNoteGroupLeafName(path); + const hasChildren = sortedPaths.some( + (other) => other !== path && other.startsWith(`${path}/`), + ); + const directCount = notes.filter((n) => n.group === path).length; + const totalCount = notes.filter((n) => isNoteGroupInside(n.group, path)).length; + + // Check if all parent folders are expanded + let visible = true; + let cur = getNoteGroupParentPath(path); + while (cur) { + if (!expandedFolders.has(cur)) { + visible = false; + break; + } + cur = getNoteGroupParentPath(cur); + } + + if (visible || depth === 0) { + items.push({ + path, + name, + depth, + hasChildren, + directCount, + totalCount, + }); + } + } + return items; + }, [noteGroups, notes, expandedFolders]); + + return ( +
+ {/* Sidebar Header */} +
+ + + {t("notes.title") || "笔记管理"} + +
+ {onImportMarkdown && ( + + )} + +
+
+ + {/* Main Navigation Scroll Area */} +
+ {/* Quick Filter Section */} +
+ + + + + + + +
+ + {/* Folders / Notebooks Section */} +
+
+ {t("notes.folders") || "笔记本 / 文件夹"} + +
+ +
+ {folderTree.length === 0 ? ( +
+ 暂无文件夹,点击 + 创建 +
+ ) : ( + folderTree.map((item) => { + const isSelected = selectedGroup === item.path; + const isExpanded = expandedFolders.has(item.path); + + return ( +
{ + onSelectGroup(item.path); + onSelectTag(null); + onSelectFilterMode("all"); + }} + > +
+ {item.hasChildren ? ( + + ) : ( + + )} + + {isExpanded ? ( + + ) : ( + + )} + {item.name} +
+ +
+ + {item.totalCount} + +
+ + + +
+
+
+ ); + }) + )} +
+
+ + {/* Tags Section */} + {allTags.length > 0 && ( +
+
+ + + {t("notes.tags") || "标签"} + +
+ +
+ {allTags.map(({ tag, count }) => { + const isSelected = selectedTag === tag; + return ( + + ); + })} +
+
+ )} +
+ + {/* Sidebar Footer */} +
+ 共 {notes.length} 篇笔记 +
+
+ ); +}; diff --git a/domain/models/connection.ts b/domain/models/connection.ts index a783d0169..ba8b59b49 100644 --- a/domain/models/connection.ts +++ b/domain/models/connection.ts @@ -380,6 +380,7 @@ export interface VaultNote { createdAt: number; updatedAt: number; order?: number; + isPinned?: boolean; } export interface ChatMessage { diff --git a/domain/notes.test.ts b/domain/notes.test.ts index 3a2b26dd5..9af7d4c21 100644 --- a/domain/notes.test.ts +++ b/domain/notes.test.ts @@ -4,6 +4,10 @@ import { buildVaultNoteMarkdownExportFiles, buildVaultNoteFromMarkdownImport, deriveNoteImportTitle, + extractAllNoteTags, + extractNoteHeadings, + extractNoteSnippet, + filterAndSortVaultNotes, getVaultNotesForExportScope, importMarkdownFilesToVaultNotes, importMarkdownPayloadsToVaultNotes, @@ -275,3 +279,70 @@ test("importMarkdownFilesToVaultNotes appends notes and skips unsupported files" assert.equal(result.notes[1].title, "Imported"); assert.equal(result.notes[1].group, "Ops"); }); + +test("extractNoteHeadings extracts markdown headings ignoring code blocks", () => { + const content = `# Title 1 +Some intro text +\`\`\`ts +# Not a heading +\`\`\` +## Subtitle 1.1 +### Deep section +`; + const headings = extractNoteHeadings(content); + assert.equal(headings.length, 3); + assert.equal(headings[0].text, "Title 1"); + assert.equal(headings[0].level, 1); + assert.equal(headings[1].text, "Subtitle 1.1"); + assert.equal(headings[1].level, 2); + assert.equal(headings[2].text, "Deep section"); + assert.equal(headings[2].level, 3); +}); + +test("extractNoteSnippet extracts clean plain text from markdown", () => { + const content = `# Title +Here is **bold** text and [a link](https://example.com) and \`code\`. +- [x] Task 1 +> quote text +`; + const snippet = extractNoteSnippet(content, 50); + assert.ok(!snippet.includes("#")); + assert.ok(!snippet.includes("[a link]")); + assert.ok(snippet.includes("bold")); + assert.ok(snippet.includes("Task 1")); +}); + +test("extractAllNoteTags aggregates and sorts tags by frequency", () => { + const notes = [ + sanitizeVaultNote({ tags: ["dev", "prod"] }), + sanitizeVaultNote({ tags: ["dev", "staging"] }), + sanitizeVaultNote({ tags: ["dev"] }), + ]; + const tags = extractAllNoteTags(notes); + assert.equal(tags[0].tag, "dev"); + assert.equal(tags[0].count, 3); +}); + +test("filterAndSortVaultNotes filters by pinned, tags, group and sorts accordingly", () => { + const notes = [ + sanitizeVaultNote({ id: "1", title: "B Note", isPinned: false, updatedAt: 100, tags: ["ssh"] }), + sanitizeVaultNote({ id: "2", title: "A Note", isPinned: true, updatedAt: 50, tags: ["ssh"] }), + sanitizeVaultNote({ id: "3", title: "C Note", isPinned: false, updatedAt: 200, group: "Ops" }), + ]; + + // Pinned first + const sorted = filterAndSortVaultNotes(notes, { sort: "updatedDesc" }); + assert.equal(sorted[0].id, "2"); // Pinned note first + assert.equal(sorted[1].id, "3"); // 200 updatedAt + assert.equal(sorted[2].id, "1"); // 100 updatedAt + + // Filter pinned only + const pinnedOnly = filterAndSortVaultNotes(notes, { filterMode: "pinned" }); + assert.equal(pinnedOnly.length, 1); + assert.equal(pinnedOnly[0].id, "2"); + + // Filter by tag + const tagFiltered = filterAndSortVaultNotes(notes, { tag: "ssh" }); + assert.equal(tagFiltered.length, 2); +}); + diff --git a/domain/notes.ts b/domain/notes.ts index 3c540da13..2589747b6 100644 --- a/domain/notes.ts +++ b/domain/notes.ts @@ -38,6 +38,7 @@ export const sanitizeVaultNote = (note: Partial): VaultNote => { createdAt, updatedAt, order: typeof note.order === "number" && Number.isFinite(note.order) ? note.order : undefined, + isPinned: note.isPinned ? true : undefined, }; }; @@ -462,3 +463,152 @@ export const importMarkdownFilesToVaultNotes = async ( skippedCount, }; }; + +export interface NoteHeadingItem { + id: string; + level: number; + text: string; + line: number; +} + +export const extractNoteHeadings = (content: string): NoteHeadingItem[] => { + if (!content) return []; + const lines = content.split("\n"); + const headings: NoteHeadingItem[] = []; + let inCodeBlock = false; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (line.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) continue; + + const match = /^(#{1,6})\s+(.+)$/.exec(line.trim()); + if (match) { + const level = match[1].length; + const text = match[2].trim(); + headings.push({ + id: `heading-${i}-${text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")}`, + level, + text, + line: i + 1, + }); + } + } + return headings; +}; + +export const extractNoteSnippet = (content: string, maxLength = 100): string => { + if (!content) return ""; + let text = content.replace(/```[\s\S]*?```/g, " "); + text = text.replace(/`([^`]+)`/g, "$1"); + text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1"); + text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"); + text = text.replace(/^#{1,6}\s+/gm, ""); + text = text.replace(/^>\s+/gm, ""); + text = text.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/gm, ""); + text = text.replace(/^\d+\.\s+/gm, ""); + text = text.replace(/\s+/g, " ").trim(); + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trim()}...`; +}; + +export const extractAllNoteTags = (notes: VaultNote[]): { tag: string; count: number }[] => { + const map = new Map(); + for (const note of notes) { + if (note.tags) { + for (const t of note.tags) { + const clean = t.trim(); + if (clean) { + map.set(clean, (map.get(clean) || 0) + 1); + } + } + } + } + return Array.from(map.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); +}; + +export type NoteSortOption = + | "updatedDesc" + | "updatedAsc" + | "createdDesc" + | "createdAsc" + | "titleAsc" + | "titleDesc" + | "custom"; + +export type NoteFilterMode = "all" | "pinned" | "recent" | "uncategorized"; + +export const filterAndSortVaultNotes = ( + notes: VaultNote[], + options: { + search?: string; + group?: string | null; + tag?: string | null; + filterMode?: NoteFilterMode; + sort?: NoteSortOption; + hosts?: Host[]; + } = {}, +): VaultNote[] => { + const { + search = "", + group = null, + tag = null, + filterMode = "all", + sort = "updatedDesc", + hosts = [], + } = options; + + const filtered = notes.filter((note) => { + if (filterMode === "pinned" && !note.isPinned) return false; + if (filterMode === "uncategorized" && note.group) return false; + if (filterMode === "recent") { + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + if (note.updatedAt < sevenDaysAgo) return false; + } + + if (group) { + if (!note.group || !isNoteGroupInside(note.group, group)) return false; + } + + if (tag) { + if (!note.tags || !note.tags.includes(tag)) return false; + } + + if (search.trim() && !matchesVaultNoteSearch(note, search, hosts)) { + return false; + } + + return true; + }); + + return [...filtered].sort((a, b) => { + if (sort !== "custom") { + if (a.isPinned !== b.isPinned) { + return a.isPinned ? -1 : 1; + } + } + + switch (sort) { + case "updatedDesc": + return b.updatedAt - a.updatedAt; + case "updatedAsc": + return a.updatedAt - b.updatedAt; + case "createdDesc": + return b.createdAt - a.createdAt; + case "createdAsc": + return a.createdAt - b.createdAt; + case "titleAsc": + return (a.title || "").localeCompare(b.title || ""); + case "titleDesc": + return (b.title || "").localeCompare(a.title || ""); + case "custom": + default: + return (a.order ?? 0) - (b.order ?? 0); + } + }); +};