diff --git a/README.md b/README.md index b70d2a39..3b94ad96 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作 - 多选合并笔记,原笔记软删除,资源关联迁移到新笔记。 - 多选移动笔记,笔记本支持拖拽排序和调整层级。 - PWA 可安装,支持静态应用壳离线打开和自动更新。 +- 已有笔记支持离线编辑草稿和本地同步队列。 - 单用户登录,密码使用 PBKDF2-SHA256 hash。 -- REST API-first,支持 API Token、MCP 和 CLI。 +- REST API-first,支持 API Token、OpenAPI、MCP 和 CLI。 ## 技术栈 @@ -104,6 +105,16 @@ content_markdown API、Agent、导入导出使用 content_text 搜索、摘要和索引使用 ``` +## API 文档 + +OpenAPI schema: + +```text +https://你的域名/api/openapi.json +``` + +仓库内文件:[docs/openapi.json](docs/openapi.json)。 + ## MCP / CLI 先在 EdgeEver 左侧 **设置** 里创建 API Token。给只读 Agent 使用时建议只勾选 `read:notebooks`、`read:memos`、`read:tags`;需要写入、移动、合并或上传附件时再增加对应 `write:*` scope。 @@ -157,6 +168,23 @@ list_notebooks list_tags ``` +本地 stdio MCP 客户端可通过 bridge 连接远程 EdgeEver: + +```sh +EDGEEVER_URL=https://你的域名 \ +EDGEEVER_TOKEN= \ +bun run mcp:stdio +``` + +也可以复用 CLI profile: + +```sh +bun run cli -- profile set prod --url https://你的域名 --token +bun run mcp:stdio -- --profile prod +``` + +Claude Desktop / Cursor / Codex 这类本地客户端可把 command 配成 `bun`,args 配成仓库内 `scripts/edgeever-mcp-stdio.mjs` 的绝对路径,并通过环境变量传入 `EDGEEVER_URL` 和 `EDGEEVER_TOKEN`。 + 最小调用示例: ```sh diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3526f795..48cc8323 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,6 @@ import { createExcerpt, + DEFAULT_MEMO_TITLE, docToMarkdown, docToText, emptyDoc, @@ -31,6 +32,7 @@ import type { Context } from "hono"; import { Hono } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { cors } from "hono/cors"; +import openApiSpec from "../../../docs/openapi.json"; type Bindings = { DB: D1Database; @@ -235,6 +237,8 @@ app.get("/api/health", (c) => }) ); +app.get("/api/openapi.json", (c) => c.json(openApiSpec)); + app.get("/api/v1/auth/session", async (c) => { const authRequired = await isAuthRequired(c.env); @@ -655,8 +659,8 @@ app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => { const contentMarkdown = input.contentMarkdown ?? ""; const contentJson = markdownToDoc(contentMarkdown); const contentText = docToText(contentJson); - const title = input.title || deriveTitle(contentText); - const excerpt = createExcerpt(contentText || title || ""); + const title = normalizeMemoTitle(input.title); + const excerpt = createExcerpt(contentText); const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); const id = createId("memo"); const now = isoNow(); @@ -787,8 +791,8 @@ app.post("/api/v1/memos/:id/revisions/:revisionId/restore", async (c) => { const contentJson = parseDoc(revision.content_json); const contentMarkdown = revision.content_markdown || docToMarkdown(contentJson); const contentText = revision.content_text || docToText(contentJson); - const title = revision.title ?? deriveTitle(contentText); - const excerpt = createExcerpt(contentText || title || ""); + const title = normalizeMemoTitle(revision.title); + const excerpt = createExcerpt(contentText); const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); const nextRevision = current.revision + 1; const now = isoNow(); @@ -1039,9 +1043,10 @@ app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) = const contentMarkdown = input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson); const contentText = docToText(contentJson); - const title = input.title ?? current.title ?? deriveTitle(contentText); + const title = + input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title); const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags); - const excerpt = createExcerpt(contentText || title || ""); + const excerpt = createExcerpt(contentText); const notebookId = input.notebookId ?? current.notebook_id; const nextRevision = current.revision + 1; const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); @@ -2629,8 +2634,8 @@ const createMemoRecord = async ( const contentMarkdown = input.contentMarkdown ?? ""; const contentJson = markdownToDoc(contentMarkdown); const contentText = docToText(contentJson); - const title = input.title || deriveTitle(contentText); - const excerpt = createExcerpt(contentText || title || ""); + const title = normalizeMemoTitle(input.title); + const excerpt = createExcerpt(contentText); const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); const id = createId("memo"); const now = isoNow(); @@ -2698,9 +2703,10 @@ const updateMemoRecord = async ( const contentMarkdown = input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson); const contentText = docToText(contentJson); - const title = input.title ?? current.title ?? deriveTitle(contentText); + const title = + input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title); const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags); - const excerpt = createExcerpt(contentText || title || ""); + const excerpt = createExcerpt(contentText); const notebookId = input.notebookId ?? current.notebook_id; const nextRevision = current.revision + 1; const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); @@ -2920,9 +2926,9 @@ const slugify = (value: string) => .replace(/^-+|-+$/g, "") .slice(0, 80); -const deriveTitle = (text: string) => { - const title = text.trim().split(/\s+/).slice(0, 10).join(" "); - return title || "Untitled memo"; +const normalizeMemoTitle = (value: string | null | undefined) => { + const title = value?.trim(); + return title || DEFAULT_MEMO_TITLE; }; const clampNumber = (value: number, min: number, max: number) => { diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index a49c1fec..376aff1a 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,4 +1,5 @@ import { + DEFAULT_MEMO_TITLE, docToMarkdown, type ApiToken, type AuthSession, @@ -20,7 +21,10 @@ import { CheckSquare, ChevronDown, ChevronRight, + CheckCircle2, Code2, + AlertTriangle, + CloudOff, ExternalLink, File as FileIcon, FilePlus2, @@ -44,6 +48,7 @@ import { Plus, Quote, Redo2, + RefreshCw, RotateCcw, Save, Search, @@ -69,7 +74,16 @@ import { } from "react"; import { api } from "@/lib/api"; import { compressImageForUpload } from "@/lib/image-compression"; -import { localDb } from "@/lib/local-db"; +import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; +import { + emptySyncQueueSummary, + getMemoUpdateQueueId, + observeSyncQueue, + queueMemoUpdate, + shouldQueueMemoSaveError, + syncQueuedChanges, + type SyncQueueSummary, +} from "@/lib/sync-queue"; import { buildNotebookTree, cn, formatDateTime, parseTagsText, type NotebookNode } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -169,6 +183,32 @@ const WorkspaceApp = ({ const [tagsOpen, setTagsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [search, setSearch] = useState(""); + const [syncSummary, setSyncSummary] = useState(emptySyncQueueSummary); + const [isOnline, setIsOnline] = useState(() => (typeof navigator === "undefined" ? true : navigator.onLine)); + const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false); + + const runQueuedSync = useCallback(async () => { + if (typeof navigator !== "undefined" && !navigator.onLine) { + setIsOnline(false); + return; + } + + setIsSyncingQueuedChanges(true); + + try { + await syncQueuedChanges({ + onSynced: async (memo) => { + queryClient.setQueryData(["memo", memo.id], { memo }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo", memo.id] }), + ]); + }, + }); + } finally { + setIsSyncingQueuedChanges(false); + } + }, [queryClient]); const notebooksQuery = useQuery({ queryKey: ["notebooks"], @@ -211,6 +251,40 @@ const WorkspaceApp = ({ writeImageCompressionPreference(imageCompressionEnabled); }, [imageCompressionEnabled]); + useEffect(() => observeSyncQueue(setSyncSummary), []); + + useEffect(() => { + const updateOnlineState = () => { + const online = navigator.onLine; + setIsOnline(online); + + if (online) { + void runQueuedSync(); + } + }; + + window.addEventListener("online", updateOnlineState); + window.addEventListener("offline", updateOnlineState); + updateOnlineState(); + + return () => { + window.removeEventListener("online", updateOnlineState); + window.removeEventListener("offline", updateOnlineState); + }; + }, [runQueuedSync]); + + useEffect(() => { + if (syncSummary.total === 0) { + return; + } + + const timer = window.setInterval(() => { + void runQueuedSync(); + }, 15_000); + + return () => window.clearInterval(timer); + }, [runQueuedSync, syncSummary.total]); + const memosQuery = useQuery({ queryKey: ["memos", memoView, selectedNotebookId, search], queryFn: () => @@ -370,7 +444,7 @@ const WorkspaceApp = ({ createMemoMutation.mutate({ notebookId: selectedNotebookId, - title: "Untitled memo", + title: DEFAULT_MEMO_TITLE, contentMarkdown: "", tags: [], }); @@ -453,6 +527,10 @@ const WorkspaceApp = ({ isLoggingOut={isLoggingOut} imageCompressionEnabled={imageCompressionEnabled} onImageCompressionChange={setImageCompressionEnabled} + syncSummary={syncSummary} + isOnline={isOnline} + isSyncingQueuedChanges={isSyncingQueuedChanges} + onSyncQueuedChanges={() => void runQueuedSync()} onOpenAssets={() => setAssetsOpen(true)} onOpenTags={() => setTagsOpen(true)} onOpenSettings={() => setSettingsOpen(true)} @@ -699,6 +777,10 @@ const NotebookPane = ({ isLoggingOut, imageCompressionEnabled, onImageCompressionChange, + syncSummary, + isOnline, + isSyncingQueuedChanges, + onSyncQueuedChanges, onOpenAssets, onOpenTags, onOpenSettings, @@ -719,6 +801,10 @@ const NotebookPane = ({ isLoggingOut: boolean; imageCompressionEnabled: boolean; onImageCompressionChange: (enabled: boolean) => void; + syncSummary: SyncQueueSummary; + isOnline: boolean; + isSyncingQueuedChanges: boolean; + onSyncQueuedChanges: () => void; onOpenAssets: () => void; onOpenTags: () => void; onOpenSettings: () => void; @@ -772,6 +858,12 @@ const NotebookPane = ({