笔记模块重构与升级

This commit is contained in:
Sammsai
2026-08-20 20:41:12 +08:00
parent afc02116a8
commit f3e67fc0aa
8 changed files with 1501 additions and 28 deletions
+179
View File
@@ -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<NoteExportMenuProps> = ({
note,
allNotes,
className = "",
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(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, "&lt;").replace(/>/g, "&gt;");
const htmlContent = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>${escapedTitle}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #333; }
h1, h2, h3 { color: #111; }
pre { background: #f4f4f5; padding: 16px; border-radius: 8px; overflow-x: auto; }
code { font-family: monospace; background: #f4f4f5; padding: 2px 6px; border-radius: 4px; }
pre code { background: none; padding: 0; }
blockquote { border-left: 4px solid #e4e4e7; margin: 0; padding-left: 16px; color: #71717a; }
</style>
</head>
<body>
<h1>${escapedTitle}</h1>
<pre style="white-space: pre-wrap;">${note.content.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre>
</body>
</html>`;
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 (
<div className={`relative inline-block ${className}`} ref={menuRef}>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("notes.export") || "导出与分享"}
onClick={() => setOpen((prev) => !prev)}
>
<Share2 size={16} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1.5 w-56 bg-popover border border-border rounded-lg shadow-lg py-1.5 z-50 text-sm animate-in fade-in-50 zoom-in-95">
{note && (
<>
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
当前笔记
</div>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleExportSingleMarkdown}
>
<FileText size={14} className="text-primary" />
<span>导出为 Markdown (.md)</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleExportSingleHtml}
>
<FileCode size={14} className="text-primary" />
<span>导出为 HTML (.html)</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleCopyMarkdown}
>
<Copy size={14} className="text-muted-foreground" />
<span>复制 Markdown 源码</span>
</button>
<div className="my-1 border-t border-border" />
</>
)}
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
全部笔记
</div>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleExportAllZip}
disabled={!allNotes.length}
>
<Download size={14} className="text-emerald-500" />
<span>导出全部为 ZIP 压缩包</span>
</button>
</div>
)}
</div>
);
};
+85
View File
@@ -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<NoteOutlineProps> = ({
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 (
<div
className={`flex flex-col h-full bg-background/95 border-l border-border select-none ${className}`}
>
<div className="flex items-center justify-between px-3.5 py-2.5 border-b border-border/80 shrink-0">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground uppercase tracking-wider">
<ListTree size={14} className="text-primary" />
<span>大纲目录 ({headings.length})</span>
</div>
{onClose && (
<button
type="button"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={onClose}
title={t("common.close") || "关闭"}
>
<X size={14} />
</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{headings.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground px-4">
<Hash size={24} className="mx-auto mb-2 opacity-30" />
<p>暂无标题大纲</p>
<p className="mt-1 text-[11px] opacity-70">
在 Markdown 中使用 #、## 编写各级标题
</p>
</div>
) : (
headings.map((item) => (
<button
key={item.id}
type="button"
className={`w-full text-left py-1.5 pr-2 rounded-md hover:bg-muted/70 text-xs truncate transition-colors flex items-center gap-1.5 ${getIndentClass(
item.level,
)}`}
onClick={() => onSelectHeading?.(item)}
title={item.text}
>
<span className="opacity-40 text-[10px] shrink-0">H{item.level}</span>
<span className="truncate">{item.text}</span>
</button>
))
)}
</div>
</div>
);
};
+331
View File
@@ -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<NotesListProps> = ({
notes,
selectedNoteId,
selectedGroup,
selectedTag,
filterMode,
hosts = [],
onSelectNote,
onCreateNote,
onTogglePinNote,
onDuplicateNote,
onDeleteNote,
className = "",
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState<NoteSortOption>("updatedDesc");
const [activeMenuNoteId, setActiveMenuNoteId] = useState<string | null>(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 (
<div
className={`flex flex-col h-full bg-background border-r border-border select-none ${className}`}
>
{/* List Header */}
<div className="p-3 border-b border-border/80 space-y-2 shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
<h2 className="font-semibold text-sm text-foreground truncate">
{headerTitle}
</h2>
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.2 rounded-full shrink-0">
{displayedNotes.length}
</span>
</div>
<div className="flex items-center gap-1">
<select
value={sortOption}
onChange={(e) => setSortOption(e.target.value as NoteSortOption)}
className="text-xs bg-muted/70 hover:bg-muted border border-border/60 rounded px-1.5 py-1 text-foreground cursor-pointer outline-none transition-colors"
title="排序方式"
>
<option value="updatedDesc">最近修改</option>
<option value="createdDesc">最新创建</option>
<option value="titleAsc">标题 A-Z</option>
<option value="titleDesc">标题 Z-A</option>
<option value="custom">自定义顺序</option>
</select>
<button
type="button"
className="px-2 py-1 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-medium rounded-md flex items-center gap-1 transition-colors shadow-sm shrink-0"
onClick={onCreateNote}
title={t("notes.newNote") || "新建笔记"}
>
<Plus size={14} />
<span>新建</span>
</button>
</div>
</div>
{/* Search input */}
<div className="relative">
<Search
size={13}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={13} />
</button>
)}
</div>
</div>
{/* Cards List */}
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{displayedNotes.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
<p>暂无匹配笔记</p>
{searchQuery ? (
<p className="mt-1 text-[11px] opacity-70">
可尝试更改搜索关键词或清除搜索条件
</p>
) : (
<button
type="button"
className="mt-3 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded text-xs transition-colors font-medium"
onClick={onCreateNote}
>
创建第一篇笔记
</button>
)}
</div>
) : (
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 (
<div
key={note.id}
className={`group relative p-2.5 rounded-lg border transition-all cursor-pointer ${
isSelected
? "bg-primary/10 border-primary/50 shadow-sm"
: "bg-card hover:bg-muted/50 border-border/70 hover:border-border"
}`}
onClick={() => onSelectNote(note.id)}
>
{/* Title & Pin Indicator */}
<div className="flex items-start justify-between gap-1 mb-1">
<div className="flex items-center gap-1.5 min-w-0">
{note.isPinned && (
<Pin
size={12}
className="text-amber-500 fill-amber-500 shrink-0"
/>
)}
<span
className={`text-xs font-semibold truncate ${
isSelected ? "text-primary" : "text-foreground"
}`}
>
{title}
</span>
</div>
{/* Actions Dropdown Trigger on hover */}
<div className="relative shrink-0">
<button
type="button"
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-opacity"
onClick={(e) => {
e.stopPropagation();
setActiveMenuNoteId(isMenuOpen ? null : note.id);
}}
>
<MoreVertical size={13} />
</button>
{isMenuOpen && (
<div
className="absolute right-0 top-full mt-1 w-32 bg-popover border border-border rounded-md shadow-lg py-1 z-50 text-xs text-foreground animate-in fade-in-50 zoom-in-95"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
className="w-full px-2.5 py-1 flex items-center gap-2 hover:bg-muted transition-colors text-left"
onClick={() => {
onTogglePinNote(note.id);
setActiveMenuNoteId(null);
}}
>
{note.isPinned ? (
<>
<PinOff size={13} className="text-muted-foreground" />
<span>取消置顶</span>
</>
) : (
<>
<Pin size={13} className="text-amber-500" />
<span>置顶笔记</span>
</>
)}
</button>
<button
type="button"
className="w-full px-2.5 py-1 flex items-center gap-2 hover:bg-muted transition-colors text-left"
onClick={() => {
onDuplicateNote(note);
setActiveMenuNoteId(null);
}}
>
<Copy size={13} className="text-muted-foreground" />
<span>创建副本</span>
</button>
<div className="my-1 border-t border-border" />
<button
type="button"
className="w-full px-2.5 py-1 flex items-center gap-2 hover:bg-destructive/10 text-destructive transition-colors text-left"
onClick={() => {
onDeleteNote(note.id);
setActiveMenuNoteId(null);
}}
>
<Trash2 size={13} />
<span>删除</span>
</button>
</div>
)}
</div>
</div>
{/* Snippet Preview */}
{snippet && (
<p className="text-[11px] text-muted-foreground line-clamp-2 leading-relaxed mb-2 font-normal">
{snippet}
</p>
)}
{/* Footer Metadata: Date, Folder, Tags */}
<div className="flex items-center justify-between text-[10px] text-muted-foreground/80 pt-0.5">
<span className="shrink-0">
{formatNoteRelativeTime(note.updatedAt)}
</span>
<div className="flex items-center gap-1 overflow-hidden ml-2">
{note.group && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.2 bg-muted rounded text-[10px] text-muted-foreground truncate max-w-[80px]">
<Folder size={9} className="opacity-70 shrink-0" />
<span className="truncate">{getNoteGroupLeafName(note.group)}</span>
</span>
)}
{note.tags && note.tags.length > 0 && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.2 bg-muted rounded text-[10px] text-muted-foreground truncate max-w-[70px]">
<Hash size={9} className="opacity-70 shrink-0" />
<span className="truncate">{note.tags[0]}</span>
{note.tags.length > 1 && (
<span className="opacity-60">+{note.tags.length - 1}</span>
)}
</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
};
+237 -28
View File
@@ -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<NotesManagerProps> = ({
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<NotesManagerProps> = ({
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<string | null>(null);
const draftTitleRef = useRef<string | null>(null);
@@ -1124,6 +1161,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
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<NotesManagerProps> = ({
>
{selectedNoteView ? (
<>
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-8 pt-6 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<NoteTitleInput
noteId={selectedNoteView.id}
@@ -1754,35 +1795,179 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
onBlur={() => flushNoteDraft()}
/>
</div>
{/* Pin Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={selectedNoteView.isPinned ? "取消置顶" : "置顶笔记"}
className={cn(
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
selectedNoteView.isPinned ? "text-amber-500" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => togglePinNoteById(selectedNoteView.id)}
>
<Pin size={16} className={selectedNoteView.isPinned ? "fill-amber-500" : ""} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{selectedNoteView.isPinned ? "取消置顶" : "置顶笔记"}
</TooltipContent>
</Tooltip>
{/* Outline Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="大纲目录"
className={cn(
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
showOutline ? "bg-secondary text-primary font-medium" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setShowOutline((prev) => !prev)}
>
<ListTree size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">大纲目录</TooltipContent>
</Tooltip>
{/* Export Menu */}
<NoteExportMenu note={selectedNoteView} allNotes={sortedNotes} />
{renderNoteExportButton(selectedNoteView)}
{renderNoteModeToggle()}
{/* Delete Note */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("action.delete")}
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={() => requestDeleteNoteById(selectedNoteView.id)}
>
<Trash2 size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("action.delete")}</TooltipContent>
</Tooltip>
</div>
{/* Tags & Folder Breadcrumb Header */}
<div className="flex flex-wrap items-center gap-1.5 px-8 pb-2 text-xs text-muted-foreground">
{selectedNoteView.group && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-muted/70 rounded-md text-[11px] text-foreground font-medium">
<Folder size={12} className="text-amber-500" />
<span>{selectedNoteView.group}</span>
</span>
)}
{selectedNoteView.tags?.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary/10 text-primary border border-primary/20 rounded-md text-[11px] font-medium"
>
<Hash size={11} className="opacity-70" />
<span>{tag}</span>
<button
type="button"
className="p-0.5 rounded-full hover:bg-primary/20 text-primary/70 hover:text-primary transition-colors"
onClick={() => removeTagFromNote(selectedNoteView.id, tag)}
title="移除标签"
>
<X size={10} />
</button>
</span>
))}
{tagInputOpen ? (
<div className="inline-flex items-center gap-1 bg-background border border-primary/60 rounded-md px-1.5 py-0.5">
<Hash size={11} className="text-muted-foreground" />
<input
type="text"
autoFocus
value={newTagText}
onChange={(e) => 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"
/>
</div>
) : (
<button
type="button"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md hover:bg-muted text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setTagInputOpen(true)}
>
<Plus size={11} />
<span>添加标签</span>
</button>
)}
</div>
<div className="flex flex-1 min-h-0 min-w-0">
<ScrollArea className="min-h-0 flex-1">
<div
className="min-h-full w-full px-8 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
noteId={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
previewEmptyLabel={t("notes.preview.empty")}
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
/>
</Suspense>
</LazyLoadBoundary>
</div>
</ScrollArea>
{showOutline && (
<div className="w-60 shrink-0 h-full">
<NoteOutline
content={selectedNoteView.content}
onClose={() => setShowOutline(false)}
/>
</div>
)}
</div>
<ScrollArea className="min-h-0 flex-1">
<div
className="min-h-full w-full px-8 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
noteId={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
previewEmptyLabel={t("notes.preview.empty")}
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
/>
</Suspense>
</LazyLoadBoundary>
</div>
</ScrollArea>
</>
) : (
<div className="flex flex-1 items-center justify-center px-4">
@@ -1833,7 +2018,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-4 pt-5 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<NoteTitleInput
noteId={overlayNoteView.id}
@@ -1845,6 +2030,30 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
onBlur={() => flushNoteDraft()}
/>
</div>
{/* Pin Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={overlayNoteView.isPinned ? "取消置顶" : "置顶笔记"}
className={cn(
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
overlayNoteView.isPinned ? "text-amber-500" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => togglePinNoteById(overlayNoteView.id)}
>
<Pin size={16} className={overlayNoteView.isPinned ? "fill-amber-500" : ""} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{overlayNoteView.isPinned ? "取消置顶" : "置顶笔记"}
</TooltipContent>
</Tooltip>
<NoteExportMenu note={overlayNoteView} allNotes={sortedNotes} />
{renderNoteExportButton(overlayNoteView)}
{renderNoteModeToggle()}
</div>
+447
View File
@@ -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<NotesSidebarProps> = ({
notes,
noteGroups,
selectedGroup,
selectedTag,
filterMode,
onSelectFilterMode,
onSelectGroup,
onSelectTag,
onCreateGroup,
onRenameGroup,
onDeleteGroup,
onImportMarkdown,
className = "",
}) => {
const { t } = useI18n();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(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<string>();
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 (
<div
className={`flex flex-col h-full bg-card/60 border-r border-border select-none text-xs ${className}`}
>
{/* Sidebar Header */}
<div className="flex items-center justify-between px-3 py-3 border-b border-border/80 shrink-0">
<span className="font-semibold text-foreground tracking-wide text-[13px] flex items-center gap-2">
<FileText size={16} className="text-primary" />
<span>{t("notes.title") || "笔记管理"}</span>
</span>
<div className="flex items-center gap-1">
{onImportMarkdown && (
<button
type="button"
className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={onImportMarkdown}
title={t("notes.importMarkdown") || "导入 Markdown"}
>
<Upload size={14} />
</button>
)}
<button
type="button"
className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onCreateGroup(null)}
title={t("notes.newGroup") || "新建文件夹"}
>
<FolderPlus size={14} />
</button>
</div>
</div>
{/* Main Navigation Scroll Area */}
<div className="flex-1 overflow-y-auto p-2 space-y-4">
{/* Quick Filter Section */}
<div className="space-y-0.5">
<button
type="button"
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
!selectedGroup && !selectedTag && filterMode === "all"
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted/70"
}`}
onClick={() => {
onSelectGroup(null);
onSelectTag(null);
onSelectFilterMode("all");
}}
>
<span className="flex items-center gap-2">
<FileText size={14} className="opacity-70" />
<span>{t("notes.allNotes") || "全部笔记"}</span>
</span>
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
{counts.all}
</span>
</button>
<button
type="button"
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
!selectedGroup && !selectedTag && filterMode === "pinned"
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted/70"
}`}
onClick={() => {
onSelectGroup(null);
onSelectTag(null);
onSelectFilterMode("pinned");
}}
>
<span className="flex items-center gap-2">
<Pin size={14} className="text-amber-500 opacity-90" />
<span>{t("notes.pinned") || "已置顶"}</span>
</span>
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
{counts.pinned}
</span>
</button>
<button
type="button"
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
!selectedGroup && !selectedTag && filterMode === "recent"
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted/70"
}`}
onClick={() => {
onSelectGroup(null);
onSelectTag(null);
onSelectFilterMode("recent");
}}
>
<span className="flex items-center gap-2">
<Clock size={14} className="text-blue-500 opacity-80" />
<span>{t("notes.recent") || "最近修改"}</span>
</span>
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
{counts.recent}
</span>
</button>
<button
type="button"
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
!selectedGroup && !selectedTag && filterMode === "uncategorized"
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted/70"
}`}
onClick={() => {
onSelectGroup(null);
onSelectTag(null);
onSelectFilterMode("uncategorized");
}}
>
<span className="flex items-center gap-2">
<FileQuestion size={14} className="text-purple-500 opacity-80" />
<span>{t("notes.uncategorized") || "未分类"}</span>
</span>
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
{counts.uncategorized}
</span>
</button>
</div>
{/* Folders / Notebooks Section */}
<div className="space-y-1">
<div className="flex items-center justify-between px-2 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<span>{t("notes.folders") || "笔记本 / 文件夹"}</span>
<button
type="button"
className="p-0.5 rounded hover:bg-muted hover:text-foreground transition-colors"
onClick={() => onCreateGroup(null)}
title="新建文件夹"
>
<Plus size={13} />
</button>
</div>
<div className="space-y-0.5">
{folderTree.length === 0 ? (
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/60 italic">
暂无文件夹,点击 + 创建
</div>
) : (
folderTree.map((item) => {
const isSelected = selectedGroup === item.path;
const isExpanded = expandedFolders.has(item.path);
return (
<div
key={item.path}
className={`group/folder flex items-center justify-between px-2 py-1 rounded-md transition-colors cursor-pointer ${
isSelected
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted/60"
}`}
style={{ paddingLeft: `${Math.max(8, item.depth * 14 + 8)}px` }}
onClick={() => {
onSelectGroup(item.path);
onSelectTag(null);
onSelectFilterMode("all");
}}
>
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{item.hasChildren ? (
<button
type="button"
className="p-0.5 rounded hover:bg-muted text-muted-foreground shrink-0"
onClick={(e) => toggleFolder(item.path, e)}
>
{isExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
{isExpanded ? (
<FolderOpen size={14} className="text-amber-500 shrink-0" />
) : (
<Folder size={14} className="text-amber-500 shrink-0" />
)}
<span className="truncate text-xs">{item.name}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[11px] opacity-60 group-hover/folder:hidden">
{item.totalCount}
</span>
<div className="hidden group-hover/folder:flex items-center gap-0.5">
<button
type="button"
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onCreateGroup(item.path);
}}
title="新建子文件夹"
>
<FolderPlus size={11} />
</button>
<button
type="button"
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onRenameGroup(item.path);
}}
title="重命名"
>
<Edit2 size={11} />
</button>
<button
type="button"
className="p-1 rounded hover:bg-background text-destructive hover:text-destructive/80"
onClick={(e) => {
e.stopPropagation();
onDeleteGroup(item.path);
}}
title="删除"
>
<Trash2 size={11} />
</button>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* Tags Section */}
{allTags.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between px-2 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<span className="flex items-center gap-1.5">
<Tag size={12} />
<span>{t("notes.tags") || "标签"}</span>
</span>
</div>
<div className="space-y-0.5">
{allTags.map(({ tag, count }) => {
const isSelected = selectedTag === tag;
return (
<button
key={tag}
type="button"
className={`w-full flex items-center justify-between px-2.5 py-1 rounded-md transition-colors text-left ${
isSelected
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted/60"
}`}
onClick={() => {
onSelectTag(isSelected ? null : tag);
onSelectGroup(null);
onSelectFilterMode("all");
}}
>
<span className="flex items-center gap-1.5 truncate">
<Hash size={12} className="opacity-50 shrink-0" />
<span className="truncate">{tag}</span>
</span>
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
{count}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Sidebar Footer */}
<div className="px-3 py-2 border-t border-border/80 text-[11px] text-muted-foreground flex items-center justify-between shrink-0">
<span>共 {notes.length} 篇笔记</span>
</div>
</div>
);
};
+1
View File
@@ -380,6 +380,7 @@ export interface VaultNote {
createdAt: number;
updatedAt: number;
order?: number;
isPinned?: boolean;
}
export interface ChatMessage {
+71
View File
@@ -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);
});
+150
View File
@@ -38,6 +38,7 @@ export const sanitizeVaultNote = (note: Partial<VaultNote>): 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<string, number>();
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);
}
});
};