From ee3de1968caf7f28e3acb89b8b3cee7999fd9ff7 Mon Sep 17 00:00:00 2001 From: cniucheng Date: Wed, 25 Feb 2026 09:20:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=8F=8C=E5=90=91?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=92=8C=E5=85=A8=E5=B1=80=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dev.err.log | 2 - .dev.log | 72 --------------- .gitignore | 4 + prisma/dev.db | Bin 196608 -> 196608 bytes src/app/page.tsx | 148 +++++++++++++++++++++++++++++- src/components/editor.tsx | 35 ++++++- src/components/search-command.tsx | 90 ++++++++++++++++-- src/lib/page-history.ts | 71 ++++++++++++++ src/lib/search-query.ts | 88 ++++++++++++++++++ src/lib/search-store.ts | 2 +- src/lib/store.ts | 10 ++ src/lib/wiki-links.ts | 52 +++++++++++ 12 files changed, 490 insertions(+), 84 deletions(-) delete mode 100644 .dev.err.log delete mode 100644 .dev.log create mode 100644 src/lib/page-history.ts create mode 100644 src/lib/search-query.ts create mode 100644 src/lib/wiki-links.ts diff --git a/.dev.err.log b/.dev.err.log deleted file mode 100644 index ad19159..0000000 --- a/.dev.err.log +++ /dev/null @@ -1,2 +0,0 @@ -⚠ Cross origin request detected from 127.0.0.1 to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this. -Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins diff --git a/.dev.log b/.dev.log deleted file mode 100644 index 100a218..0000000 --- a/.dev.log +++ /dev/null @@ -1,72 +0,0 @@ - -> note-ai@1.0.0105 dev -> next dev -p 3001 - -▲ Next.js 16.1.1 (Turbopack) -- Local: http://localhost:3001 -- Network: http://198.18.0.1:3001 -- Environments: .env - -✓ Starting... -✓ Ready in 1339ms - GET / 200 in 2.5s (compile: 1896ms, proxy.ts: 145ms, render: 479ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 164ms (compile: 104ms, proxy.ts: 9ms, render: 51ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 32ms (compile: 3ms, proxy.ts: 6ms, render: 22ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 4ms, render: 21ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 5ms, render: 21ms) -prisma:query SELECT `main`.`Page`.`id` FROM `main`.`Page` WHERE (`main`.`Page`.`id` = ? AND 1=1) LIMIT ? OFFSET ? -prisma:query UPDATE `main`.`Page` SET `content` = ?, `updatedAt` = ? WHERE (`main`.`Page`.`id` = ? AND 1=1) RETURNING `id` AS `id`, `title` AS `title`, `content` AS `content`, `tags` AS `tags`, `icon` AS `icon`, `type` AS `type`, `parentId` AS `parentId`, `createdAt` AS `createdAt`, `updatedAt` AS `updatedAt`, `order` AS `order`, `isLocked` AS `isLocked` - PUT /api/pages/d039b54a-ab1c-4341-a14a-70b1b1786118 200 in 898ms (compile: 865ms, proxy.ts: 8ms, render: 25ms) - GET /settings 200 in 208ms (compile: 182ms, proxy.ts: 5ms, render: 21ms) -prisma:query SELECT 1 -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 42ms (compile: 7ms, proxy.ts: 14ms, render: 21ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 31ms (compile: 4ms, proxy.ts: 10ms, render: 17ms) -✓ Compiled in 180ms - GET /settings 200 in 116ms (compile: 64ms, proxy.ts: 5ms, render: 47ms) - GET /settings 200 in 38ms (compile: 4ms, proxy.ts: 5ms, render: 29ms) -✓ Compiled in 135ms - GET /settings 200 in 200ms (compile: 61ms, proxy.ts: 4ms, render: 135ms) -prisma:query SELECT 1 -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 38ms (compile: 4ms, proxy.ts: 8ms, render: 25ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 23ms (compile: 3ms, proxy.ts: 9ms, render: 11ms) -✓ Compiled in 100ms -prisma:query SELECT 1 -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 20ms (compile: 3ms, proxy.ts: 5ms, render: 12ms) - GET /settings 200 in 92ms (compile: 69ms, proxy.ts: 6ms, render: 17ms) - GET / 200 in 129ms (compile: 101ms, proxy.ts: 6ms, render: 22ms) -prisma:query SELECT 1 -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 36ms (compile: 10ms, proxy.ts: 13ms, render: 14ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 28ms (compile: 7ms, proxy.ts: 11ms, render: 10ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 25ms (compile: 5ms, proxy.ts: 9ms, render: 11ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 18ms (compile: 3ms, proxy.ts: 7ms, render: 8ms) -✓ Compiled in 455ms -✓ Compiled in 121ms -✓ Compiled in 87ms - GET /settings 200 in 76ms (compile: 57ms, proxy.ts: 5ms, render: 15ms) -prisma:query SELECT 1 -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 40ms (compile: 4ms, proxy.ts: 11ms, render: 26ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 38ms (compile: 5ms, proxy.ts: 8ms, render: 25ms) - GET / 200 in 93ms (compile: 73ms, proxy.ts: 4ms, render: 17ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 22ms (compile: 3ms, proxy.ts: 7ms, render: 12ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 20ms (compile: 6ms, proxy.ts: 6ms, render: 8ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 29ms (compile: 1969µs, proxy.ts: 4ms, render: 22ms) -prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ? - GET /api/pages 200 in 28ms (compile: 1741µs, proxy.ts: 4ms, render: 22ms) diff --git a/.gitignore b/.gitignore index 1df4829..2668182 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +*.log +*.err.log +.dev*.log +.dev*.err.log # env files (can opt-in for committing if needed) .env* diff --git a/prisma/dev.db b/prisma/dev.db index 1c28709dc7eef341bc00d97377b268191ab1bf03..527797c577a296e45a9e93c81b89d69ba42157ec 100644 GIT binary patch delta 441 zcmZo@;Av>!nIO%0c%qCm^N<9WJ;9+2ZAP|q|V5s~@ zNkInDP*zqTZ*!>phn}-an7VD<2~C77+D;q_Zu*pAiHb&zVl3M zQUn~r3Ur9eiERfM*$h}_FtEOjf*-_=5msvzzQP1EvcAa^s*5 delta 307 zcmZo@;Av>!nIO%WFj2;tF=1oEviEHD415BN^%EO+PCojfa{7ctjE)>T+4y)vATXH9#0oYWfb4cad9)>m>wgN^L2f@jMD>FF>+47Z^&40`O03Z8B OpsG#m-t=(jd552 diff --git a/src/app/page.tsx b/src/app/page.tsx index 58bd9ec..ddb9d7a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,7 @@ import { SidebarContent, ResizableSidebar } from "@/components/sidebar"; import { Editor } from "@/components/editor"; import { useEditorStore } from "@/lib/store"; -import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen } from "lucide-react"; +import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react"; import { useSettingsStore } from "@/lib/settings-store"; import { exportPageAsMarkdown } from "@/lib/export"; import { useMemo, useState } from "react"; @@ -12,6 +12,8 @@ import { useSearchStore } from "@/lib/search-store"; import { type Editor as TiptapEditor } from "@tiptap/react"; import { ImportProvider } from "@/components/import-context"; import { AIChatPanel } from "@/components/chat/ai-chat-panel"; +import { getBacklinks, getOutgoingLinks, getUnresolvedLinkTitles } from "@/lib/wiki-links"; +import { getPageHistory } from "@/lib/page-history"; const ICONS = [ "📄", "📝", "📒", "📘", "📙", "📕", "📗", "📚", @@ -22,7 +24,7 @@ const ICONS = [ ]; export default function Home() { - const { activePageId, pages, updatePage } = useEditorStore(); + const { activePageId, pages, updatePage, setActivePageId, addPage } = useEditorStore(); const { openSearchWithTag } = useSearchStore(); const { timezone } = useSettingsStore(); @@ -47,6 +49,26 @@ export default function Home() { return result; }, [activePage, pages]); + const outgoingLinks = useMemo(() => { + if (!activePage || activePage.type !== "file") return []; + return getOutgoingLinks(activePage, pages); + }, [activePage, pages]); + + const backlinks = useMemo(() => { + if (!activePage || activePage.type !== "file") return []; + return getBacklinks(activePage, pages); + }, [activePage, pages]); + + const unresolvedLinks = useMemo(() => { + if (!activePage || activePage.type !== "file") return []; + return getUnresolvedLinkTitles(activePage, pages); + }, [activePage, pages]); + + const localHistory = useMemo(() => { + if (!activePage || activePage.type !== "file") return []; + return getPageHistory(activePage.id); + }, [activePage]); + const addTag = () => { if (!activePage) return; const next = tagInput.trim(); @@ -57,6 +79,17 @@ export default function Home() { setIsAddingTag(false); }; + const handleOpenWikiLink = async (title: string) => { + const target = pages.find((p) => p.type === "file" && (p.title || "").trim() === title); + if (target) { + setActivePageId(target.id); + return; + } + const shouldCreate = window.confirm(`未找到页面“${title}”。是否立即创建?`); + if (!shouldCreate) return; + await addPage(null, "file", { title, content: "" }); + }; + return (
@@ -250,8 +283,119 @@ export default function Home() { onEditorReady={setEditor} onToggleAI={() => setIsChatOpen(!isChatOpen)} onExport={() => exportPageAsMarkdown(activePage)} + onOpenWikiLink={handleOpenWikiLink} editable={!activePage.isLocked} /> + +
+
+

关联页面

+

在正文中使用 `[[页面名]]` 自动建立链接关系,支持 `Ctrl/Cmd + 点击` 跳转。

+
+ {outgoingLinks.length > 0 ? ( + outgoingLinks.map((item) => ( + + )) + ) : ( + 暂无关联页面 + )} +
+
+ +
+

反向链接

+

这些页面提到了当前页面。

+
+ {backlinks.length > 0 ? ( + backlinks.map((item) => ( + + )) + ) : ( + 暂无反向链接 + )} +
+
+ +
+

未解析链接

+

这些 `[[页面名]]` 还没有对应页面,可一键创建。

+
+ {unresolvedLinks.length > 0 ? ( + unresolvedLinks.map((title) => ( + + )) + ) : ( + 全部链接已解析 + )} +
+
+ +
+
+ +

本地历史版本

+
+

自动保存最近修改快照(当前浏览器本地)。

+
+ {localHistory.length > 0 ? ( + localHistory.slice(0, 8).map((snapshot, index) => ( +
+
+
+ {snapshot.title || "无标题"} +
+
+ {new Date(snapshot.timestamp).toLocaleString("zh-CN", { + timeZone: timezone || "Asia/Shanghai", + hour12: false, + })} +
+
+ +
+ )) + ) : ( + 暂无历史记录 + )} +
+
+
)} diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 26d835a..8a42d7c 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -37,10 +37,31 @@ interface EditorProps { onEditorReady?: (editor: TiptapEditor) => void; onToggleAI?: () => void; onExport?: () => void; + onOpenWikiLink?: (title: string) => void; editable?: boolean; } -export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, editable = true }: EditorProps) { +function resolveWikiLinkAtPos(doc: TiptapEditor["state"]["doc"], pos: number): string | null { + const $pos = doc.resolve(pos); + const start = $pos.start($pos.depth); + const end = $pos.end($pos.depth); + const text = doc.textBetween(start, end, "\n"); + const offset = pos - start; + const re = /\[\[([^[\]]+)\]\]/g; + + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + const from = match.index; + const to = from + match[0].length; + if (offset >= from && offset <= to) { + const title = (match[1] || "").trim(); + return title || null; + } + } + return null; +} + +export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, onOpenWikiLink, editable = true }: EditorProps) { const [showAI, setShowAI] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const abortControllerRef = useRef(null); @@ -130,6 +151,18 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${lineHeight}; --table-line-height: ${tableLineHeight};`, spellcheck: "false", }, + handleClick: (view, pos, event) => { + if (!onOpenWikiLink) return false; + const pointerEvent = event as MouseEvent; + if (!pointerEvent.metaKey && !pointerEvent.ctrlKey) return false; + + const title = resolveWikiLinkAtPos(view.state.doc, pos); + if (!title) return false; + + pointerEvent.preventDefault(); + onOpenWikiLink(title); + return true; + }, }, editable, immediatelyRender: false, diff --git a/src/components/search-command.tsx b/src/components/search-command.tsx index 56d1550..04b5bf1 100644 --- a/src/components/search-command.tsx +++ b/src/components/search-command.tsx @@ -1,14 +1,69 @@ "use client"; import { useSearchStore } from "@/lib/search-store"; -import { useEffect } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { useEditorStore } from "@/lib/store"; import { Command } from "cmdk"; import { Search, FileText, Folder } from "lucide-react"; +import { filterPagesByQuery, parseSearchQuery } from "@/lib/search-query"; + +const RECENT_PAGE_IDS_KEY = "noteai-recent-page-ids"; +const MAX_RECENT_PAGES = 30; + +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function highlightText(text: string, terms: string[]) { + if (!text || terms.length === 0) return text; + const normalized = Array.from(new Set(terms.filter(Boolean).map((t) => t.toLowerCase()))); + if (normalized.length === 0) return text; + + const pattern = normalized.map(escapeRegExp).join("|"); + const regex = new RegExp(`(${pattern})`, "ig"); + const parts = text.split(regex); + + return parts.map((part, idx) => { + const matched = normalized.includes(part.toLowerCase()); + return matched ? ( + + {part} + + ) : ( + {part} + ); + }); +} export function SearchCommand() { const { isOpen, setOpen, query, setQuery } = useSearchStore(); const { pages, setActivePageId } = useEditorStore(); + const [recentPageIds, setRecentPageIds] = useState(() => { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(RECENT_PAGE_IDS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : []; + } catch { + return []; + } + }); + + const parsedQuery = useMemo(() => parseSearchQuery(query), [query]); + const filteredPages = useMemo(() => { + const items = filterPagesByQuery(pages, query); + const recentRank = new Map(recentPageIds.map((id, idx) => [id, idx])); + + return [...items].sort((a, b) => { + const ra = recentRank.has(a.id) ? (recentRank.get(a.id) as number) : Number.MAX_SAFE_INTEGER; + const rb = recentRank.has(b.id) ? (recentRank.get(b.id) as number) : Number.MAX_SAFE_INTEGER; + if (ra !== rb) return ra - rb; + const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; + const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; + return tb - ta; + }); + }, [pages, query, recentPageIds]); useEffect(() => { const down = (e: KeyboardEvent) => { @@ -27,34 +82,57 @@ export function SearchCommand() { command(); }; + const rememberVisitedPage = (pageId: string) => { + setRecentPageIds((prev) => { + const next = [pageId, ...prev.filter((id) => id !== pageId)].slice(0, MAX_RECENT_PAGES); + try { + localStorage.setItem(RECENT_PAGE_IDS_KEY, JSON.stringify(next)); + } catch { + // ignore storage failures + } + return next; + }); + }; + if (!isOpen) return null; return (
setOpen(false)} />
- +
+
+ 语法:`tag:标签` `type:file|folder` `updated:7d` +
未找到匹配结果 - {pages.map((page) => ( + {filteredPages.map((page) => ( runCommand(() => setActivePageId(page.id))} + onSelect={() => + runCommand(() => { + setActivePageId(page.id); + rememberVisitedPage(page.id); + }) + } className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground" > {page.type === "folder" ? : } - {page.title || "无标题"} + {highlightText(page.title || "无标题", parsedQuery.textTerms)} + {page.tags && page.tags.length > 0 && ( + {page.tags.slice(0, 2).join(" · ")} + )} ))} diff --git a/src/lib/page-history.ts b/src/lib/page-history.ts new file mode 100644 index 0000000..acf2421 --- /dev/null +++ b/src/lib/page-history.ts @@ -0,0 +1,71 @@ +import type { Page } from "@/lib/store"; + +const MAX_SNAPSHOTS_PER_PAGE = 30; +const MIN_SNAPSHOT_INTERVAL_MS = 15_000; + +export type PageSnapshot = { + title: string; + content: string; + timestamp: string; +}; + +function getKey(pageId: string): string { + return `noteai:history:${pageId}`; +} + +function canUseStorage(): boolean { + return typeof window !== "undefined" && typeof localStorage !== "undefined"; +} + +export function getPageHistory(pageId: string): PageSnapshot[] { + if (!canUseStorage()) return []; + try { + const raw = localStorage.getItem(getKey(pageId)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item): item is PageSnapshot => + item && + typeof item === "object" && + typeof item.title === "string" && + typeof item.content === "string" && + typeof item.timestamp === "string" + ); + } catch { + return []; + } +} + +export function capturePageSnapshot(page: Pick): void { + if (!canUseStorage()) return; + const history = getPageHistory(page.id); + const latest = history[0]; + + if (latest && latest.title === page.title && latest.content === page.content) { + return; + } + + if (latest) { + const gap = Date.now() - new Date(latest.timestamp).getTime(); + if (!Number.isNaN(gap) && gap < MIN_SNAPSHOT_INTERVAL_MS) { + return; + } + } + + const next: PageSnapshot[] = [ + { + title: page.title || "无标题", + content: page.content || "", + timestamp: new Date().toISOString(), + }, + ...history, + ].slice(0, MAX_SNAPSHOTS_PER_PAGE); + + try { + localStorage.setItem(getKey(page.id), JSON.stringify(next)); + } catch { + // Ignore storage errors. + } +} + diff --git a/src/lib/search-query.ts b/src/lib/search-query.ts new file mode 100644 index 0000000..f3294b7 --- /dev/null +++ b/src/lib/search-query.ts @@ -0,0 +1,88 @@ +import type { Page } from "@/lib/store"; + +type ParsedQuery = { + textTerms: string[]; + tagTerms: string[]; + typeTerm: "file" | "folder" | null; + updatedDays: number | null; +}; + +function stripHtml(input: string): string { + return (input || "").replace(/<[^>]+>/g, " "); +} + +export function parseSearchQuery(rawQuery: string): ParsedQuery { + const tokens = rawQuery + .trim() + .split(/\s+/) + .filter(Boolean); + + const parsed: ParsedQuery = { + textTerms: [], + tagTerms: [], + typeTerm: null, + updatedDays: null, + }; + + for (const token of tokens) { + const lower = token.toLowerCase(); + if (lower.startsWith("tag:")) { + const value = token.slice(4).trim(); + if (value) parsed.tagTerms.push(value.toLowerCase()); + continue; + } + if (lower.startsWith("type:")) { + const value = token.slice(5).trim().toLowerCase(); + if (value === "file" || value === "folder") parsed.typeTerm = value; + continue; + } + if (lower.startsWith("updated:")) { + const value = token.slice(8).trim().toLowerCase(); + const match = /^(\d+)d$/.exec(value); + if (match) { + parsed.updatedDays = Number.parseInt(match[1], 10); + } + continue; + } + parsed.textTerms.push(lower); + } + + return parsed; +} + +export function filterPagesByQuery(pages: Page[], rawQuery: string): Page[] { + const q = parseSearchQuery(rawQuery); + const now = Date.now(); + + return pages.filter((page) => { + if (q.typeTerm && page.type !== q.typeTerm) return false; + + if (q.tagTerms.length > 0) { + const tagSet = new Set((page.tags || []).map((t) => t.toLowerCase())); + const allTagsMatched = q.tagTerms.every((t) => tagSet.has(t)); + if (!allTagsMatched) return false; + } + + if (q.updatedDays !== null) { + if (!page.updatedAt) return false; + const diffMs = now - new Date(page.updatedAt).getTime(); + const maxMs = q.updatedDays * 24 * 60 * 60 * 1000; + if (Number.isNaN(diffMs) || diffMs > maxMs) return false; + } + + if (q.textTerms.length > 0) { + const haystack = [ + page.title || "", + stripHtml(page.content || ""), + ...(page.tags || []), + ] + .join(" ") + .toLowerCase(); + const allTermsMatched = q.textTerms.every((term) => haystack.includes(term)); + if (!allTermsMatched) return false; + } + + return true; + }); +} + diff --git a/src/lib/search-store.ts b/src/lib/search-store.ts index c31480f..9b99a13 100644 --- a/src/lib/search-store.ts +++ b/src/lib/search-store.ts @@ -13,5 +13,5 @@ export const useSearchStore = create((set) => ({ query: "", setOpen: (isOpen) => set({ isOpen }), setQuery: (query) => set({ query }), - openSearchWithTag: (tag) => set({ isOpen: true, query: tag }), + openSearchWithTag: (tag) => set({ isOpen: true, query: `tag:${tag}` }), })); diff --git a/src/lib/store.ts b/src/lib/store.ts index a8cafc9..f1549d9 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { capturePageSnapshot } from "@/lib/page-history"; export interface Page { id: string; @@ -87,6 +88,15 @@ export const useEditorStore = create()( updatePage: async (id, data) => { const prevPages = get().pages; + const current = prevPages.find((p) => p.id === id); + + if (current && (data.content !== undefined || data.title !== undefined)) { + capturePageSnapshot({ + id: current.id, + title: current.title, + content: current.content, + }); + } set((state) => ({ pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)), diff --git a/src/lib/wiki-links.ts b/src/lib/wiki-links.ts new file mode 100644 index 0000000..81d5abd --- /dev/null +++ b/src/lib/wiki-links.ts @@ -0,0 +1,52 @@ +import type { Page } from "@/lib/store"; + +const WIKI_LINK_RE = /\[\[([^[\]]+)\]\]/g; + +function stripHtml(input: string): string { + return input.replace(/<[^>]+>/g, " "); +} + +export function extractWikiLinkTitles(content: string): string[] { + const text = stripHtml(content || ""); + const result: string[] = []; + const seen = new Set(); + + let match: RegExpExecArray | null; + while ((match = WIKI_LINK_RE.exec(text)) !== null) { + const title = (match[1] || "").trim(); + if (!title || seen.has(title)) continue; + seen.add(title); + result.push(title); + } + + return result; +} + +export function getOutgoingLinks(page: Page, pages: Page[]): Page[] { + const titles = extractWikiLinkTitles(page.content || ""); + if (titles.length === 0) return []; + const titleSet = new Set(titles); + return pages.filter((p) => p.id !== page.id && p.type === "file" && titleSet.has((p.title || "").trim())); +} + +export function getUnresolvedLinkTitles(page: Page, pages: Page[]): string[] { + const titles = extractWikiLinkTitles(page.content || ""); + if (titles.length === 0) return []; + const existing = new Set( + pages + .filter((p) => p.type === "file") + .map((p) => (p.title || "").trim()) + .filter(Boolean) + ); + return titles.filter((title) => !existing.has(title)); +} + +export function getBacklinks(target: Page, pages: Page[]): Page[] { + const targetTitle = (target.title || "").trim(); + if (!targetTitle) return []; + return pages.filter((p) => { + if (p.id === target.id || p.type !== "file") return false; + const titles = extractWikiLinkTitles(p.content || ""); + return titles.includes(targetTitle); + }); +}