feat: add offline sync and agent docs
This commit is contained in:
1 parent
d3c5310b9d
commit
377b7b9f1d
9 files changed
+2427
-166
No files matched your search
@@ -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=<api-token> \
|
||||
bun run mcp:stdio
|
||||
```
|
||||
|
||||
也可以复用 CLI profile:
|
||||
|
||||
```sh
|
||||
bun run cli -- profile set prod --url https://你的域名 --token <api-token>
|
||||
bun run mcp:stdio -- --profile prod
|
||||
```
|
||||
|
||||
Claude Desktop / Cursor / Codex 这类本地客户端可把 command 配成 `bun`,args 配成仓库内 `scripts/edgeever-mcp-stdio.mjs` 的绝对路径,并通过环境变量传入 `EDGEEVER_URL` 和 `EDGEEVER_TOKEN`。
|
||||
|
||||
最小调用示例:
|
||||
|
||||
```sh
|
||||
|
||||
+19
-13
@@ -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) => {
|
||||
|
||||
+474
-152
@@ -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<SyncQueueSummary>(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 = ({
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-emerald-100 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<SyncStatusBar
|
||||
summary={syncSummary}
|
||||
isOnline={isOnline}
|
||||
isSyncing={isSyncingQueuedChanges}
|
||||
onSyncNow={onSyncQueuedChanges}
|
||||
/>
|
||||
<label className="mb-3 flex min-h-10 items-center justify-between gap-3 rounded-md border border-emerald-100 bg-emerald-50/70 px-3 py-2">
|
||||
<span className="min-w-0 text-sm font-medium text-slate-700">压缩图片</span>
|
||||
<input
|
||||
@@ -806,6 +898,78 @@ const NotebookPane = ({
|
||||
);
|
||||
};
|
||||
|
||||
const SyncStatusBar = ({
|
||||
summary,
|
||||
isOnline,
|
||||
isSyncing,
|
||||
onSyncNow,
|
||||
}: {
|
||||
summary: SyncQueueSummary;
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
onSyncNow: () => void;
|
||||
}) => {
|
||||
const hasQueuedWork = summary.total > 0;
|
||||
const label = getSyncStatusLabel(summary, isOnline, isSyncing);
|
||||
const statusClassName = !isOnline
|
||||
? "border-slate-200 bg-slate-50 text-slate-600"
|
||||
: summary.conflict > 0
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: hasQueuedWork
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-emerald-100 bg-white text-slate-500";
|
||||
|
||||
return (
|
||||
<div className={cn("mb-3 flex min-h-10 items-center gap-2 rounded-md border px-3 py-2", statusClassName)}>
|
||||
{!isOnline ? (
|
||||
<CloudOff className="h-4 w-4 shrink-0" />
|
||||
) : summary.conflict > 0 ? (
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
) : hasQueuedWork || isSyncing ? (
|
||||
<RefreshCw className={cn("h-4 w-4 shrink-0", isSyncing && "animate-spin")} />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
|
||||
{hasQueuedWork ? (
|
||||
<button
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/70 disabled:opacity-50"
|
||||
type="button"
|
||||
title="立即同步"
|
||||
disabled={!isOnline || isSyncing}
|
||||
onClick={onSyncNow}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSyncing: boolean) => {
|
||||
if (!isOnline) {
|
||||
return summary.total > 0 ? `离线,${summary.total} 项待同步` : "离线";
|
||||
}
|
||||
|
||||
if (isSyncing || summary.syncing > 0) {
|
||||
return "同步中";
|
||||
}
|
||||
|
||||
if (summary.conflict > 0) {
|
||||
return `${summary.conflict} 项同步冲突`;
|
||||
}
|
||||
|
||||
if (summary.error > 0) {
|
||||
return `${summary.error} 项等待重试`;
|
||||
}
|
||||
|
||||
if (summary.pending > 0) {
|
||||
return `${summary.pending} 项待同步`;
|
||||
}
|
||||
|
||||
return "已同步";
|
||||
};
|
||||
|
||||
const NotebookTreeItem = ({
|
||||
node,
|
||||
depth,
|
||||
@@ -991,6 +1155,7 @@ const MemoListPane = ({
|
||||
onMoveSelectedMemos: (notebookId: string) => void;
|
||||
}) => {
|
||||
const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(notebook?.id ?? notebooks[0]?.id ?? "");
|
||||
const memoGroups = useMemo(() => groupMemosByMonth(memos), [memos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notebook?.id) {
|
||||
@@ -999,125 +1164,159 @@ const MemoListPane = ({
|
||||
}, [notebook?.id]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
<header className="border-b border-emerald-100 bg-white px-4 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] lg:py-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button className="lg:hidden" size="icon" variant="ghost" title="打开笔记本" onClick={onBackToNotebooks}>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-slate-950 lg:text-sm">
|
||||
{view === "trash" ? "回收站" : notebook?.name ?? "全部笔记"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{memos.length} {view === "trash" ? "trashed" : "memos"}
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
<header className="border-b border-emerald-100 bg-white px-4 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] lg:py-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button className="lg:hidden" size="icon" variant="ghost" title="打开笔记本" onClick={onBackToNotebooks}>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-slate-950 lg:text-sm">
|
||||
{view === "trash" ? "回收站" : notebook?.name ?? "全部笔记"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{memos.length} {view === "trash" ? "条已删除" : "条笔记"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="hidden lg:inline-flex"
|
||||
size="icon"
|
||||
variant="solid"
|
||||
title="新建笔记"
|
||||
onClick={onCreateMemo}
|
||||
disabled={!notebook || isCreating || view === "trash"}
|
||||
>
|
||||
<FilePlus2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="hidden lg:inline-flex"
|
||||
size="icon"
|
||||
variant="solid"
|
||||
<label className="flex h-9 items-center gap-2 rounded-md border border-emerald-100 bg-emerald-50/70 px-3 text-sm text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400"
|
||||
placeholder="搜索笔记"
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
{selectedMemoIds.size > 0 ? (
|
||||
<div className="sticky top-0 z-10 mb-3 flex flex-wrap items-center justify-between gap-2 rounded-md border border-emerald-100 bg-white px-3 py-2 shadow-panel">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckSquare className="h-4 w-4 text-emerald-700" />
|
||||
{selectedMemoIds.size} selected
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 max-w-40 rounded-md border border-emerald-100 bg-emerald-50/70 px-2 text-xs text-emerald-900 outline-none disabled:opacity-50"
|
||||
value={moveTargetNotebookId}
|
||||
disabled={view === "trash" || notebooks.length === 0 || isMoving}
|
||||
onChange={(event) => setMoveTargetNotebookId(event.target.value)}
|
||||
title="移动到笔记本"
|
||||
>
|
||||
{notebooks.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={() => onMoveSelectedMemos(moveTargetNotebookId)}
|
||||
disabled={!moveTargetNotebookId || isMoving || view === "trash"}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
移动
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onClick={onMerge}
|
||||
disabled={selectedMemoIds.size < 2 || isMerging || view === "trash"}
|
||||
>
|
||||
<Merge className="h-4 w-4" />
|
||||
合并
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="px-2 py-4 text-sm text-slate-500">加载中</div>
|
||||
) : memos.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-slate-300 bg-white px-4 py-8 text-center text-sm text-slate-500">
|
||||
{view === "trash" ? "回收站为空" : "暂无笔记"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-sm border-y border-slate-200 bg-white">
|
||||
{memoGroups.map((group) => (
|
||||
<section key={group.key}>
|
||||
<div className="sticky top-0 z-[4] flex h-9 items-center justify-between border-b border-slate-200 bg-white/95 px-4 text-sm font-semibold text-slate-500 backdrop-blur">
|
||||
<span>{group.label}</span>
|
||||
<span>{group.items.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
{group.items.map((memo) => (
|
||||
<MemoCard
|
||||
key={memo.id}
|
||||
memo={memo}
|
||||
selected={memo.id === selectedMemoId}
|
||||
checked={selectedMemoIds.has(memo.id)}
|
||||
multiSelectKeyDown={multiSelectKeyDown}
|
||||
onOpen={() => onOpenMemo(memo.id)}
|
||||
onToggle={() => onToggleMemo(memo.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMemoIds.size === 0 ? (
|
||||
<button
|
||||
className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 flex h-14 items-center gap-2 rounded-full border border-emerald-200 bg-emerald-100 px-5 text-sm font-semibold text-emerald-950 shadow-panel transition hover:bg-emerald-200 disabled:opacity-50 lg:hidden"
|
||||
title="新建笔记"
|
||||
onClick={onCreateMemo}
|
||||
disabled={!notebook || isCreating || view === "trash"}
|
||||
disabled={!notebook || isCreating}
|
||||
hidden={view === "trash"}
|
||||
>
|
||||
<FilePlus2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 rounded-md border border-emerald-100 bg-emerald-50/70 px-3 text-sm text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400"
|
||||
placeholder="Search memos"
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
{selectedMemoIds.size > 0 ? (
|
||||
<div className="sticky top-0 z-10 mb-3 flex flex-wrap items-center justify-between gap-2 rounded-md border border-emerald-100 bg-white px-3 py-2 shadow-panel">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckSquare className="h-4 w-4 text-emerald-700" />
|
||||
{selectedMemoIds.size} selected
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 max-w-40 rounded-md border border-emerald-100 bg-emerald-50/70 px-2 text-xs text-emerald-900 outline-none disabled:opacity-50"
|
||||
value={moveTargetNotebookId}
|
||||
disabled={view === "trash" || notebooks.length === 0 || isMoving}
|
||||
onChange={(event) => setMoveTargetNotebookId(event.target.value)}
|
||||
title="移动到笔记本"
|
||||
>
|
||||
{notebooks.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={() => onMoveSelectedMemos(moveTargetNotebookId)}
|
||||
disabled={!moveTargetNotebookId || isMoving || view === "trash"}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
移动
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onClick={onMerge}
|
||||
disabled={selectedMemoIds.size < 2 || isMerging || view === "trash"}
|
||||
>
|
||||
<Merge className="h-4 w-4" />
|
||||
合并
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FilePlus2 className="h-5 w-5" />
|
||||
新建
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="px-2 py-4 text-sm text-slate-500">加载中</div>
|
||||
) : memos.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-slate-300 bg-white px-4 py-8 text-center text-sm text-slate-500">
|
||||
{view === "trash" ? "回收站为空" : "No memos"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memos.map((memo) => (
|
||||
<MemoCard
|
||||
key={memo.id}
|
||||
memo={memo}
|
||||
selected={memo.id === selectedMemoId}
|
||||
checked={selectedMemoIds.has(memo.id)}
|
||||
multiSelectKeyDown={multiSelectKeyDown}
|
||||
onOpen={() => onOpenMemo(memo.id)}
|
||||
onToggle={() => onToggleMemo(memo.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMemoIds.size === 0 ? (
|
||||
<button
|
||||
className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 flex h-14 items-center gap-2 rounded-full border border-emerald-200 bg-emerald-100 px-5 text-sm font-semibold text-emerald-950 shadow-panel transition hover:bg-emerald-200 disabled:opacity-50 lg:hidden"
|
||||
title="新建笔记"
|
||||
onClick={onCreateMemo}
|
||||
disabled={!notebook || isCreating}
|
||||
hidden={view === "trash"}
|
||||
>
|
||||
<FilePlus2 className="h-5 w-5" />
|
||||
新建
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const groupMemosByMonth = (memos: MemoSummary[]) => {
|
||||
const groups: Array<{ key: string; label: string; items: MemoSummary[] }> = [];
|
||||
|
||||
for (const memo of memos) {
|
||||
const date = new Date(memo.updatedAt);
|
||||
const key = Number.isNaN(date.getTime())
|
||||
? "unknown"
|
||||
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
const label = Number.isNaN(date.getTime())
|
||||
? "未知时间"
|
||||
: new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long" }).format(date);
|
||||
const current = groups[groups.length - 1];
|
||||
|
||||
if (current?.key === key) {
|
||||
current.items.push(memo);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({ key, label, items: [memo] });
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const MemoCard = ({
|
||||
memo,
|
||||
selected,
|
||||
@@ -1134,6 +1333,8 @@ const MemoCard = ({
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const handledModifierPointerRef = useRef(false);
|
||||
const memoTitle = getMemoTitle(memo.title);
|
||||
const memoExcerpt = memo.excerpt.trim() || "空笔记";
|
||||
|
||||
const shouldToggleSelection = (event: MouseEvent<HTMLElement>) =>
|
||||
event.ctrlKey || event.metaKey || multiSelectKeyDown;
|
||||
@@ -1191,32 +1392,31 @@ const MemoCard = ({
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"rounded-md border bg-white p-3 transition",
|
||||
selected ? "border-emerald-300 shadow-panel" : "border-emerald-100 hover:border-emerald-200",
|
||||
checked && "border-emerald-300 bg-emerald-50/70"
|
||||
"border-b border-slate-200 transition last:border-b-0",
|
||||
selected ? "bg-slate-200" : checked ? "bg-emerald-50/80" : "bg-white hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex min-h-[128px] items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
className="mt-1 h-5 w-5 shrink-0 rounded border-emerald-300 text-emerald-600"
|
||||
aria-label={`选择 ${memo.title ?? memo.excerpt}`}
|
||||
className="ml-4 mt-5 h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600"
|
||||
aria-label={`选择 ${memoTitle}`}
|
||||
/>
|
||||
<button
|
||||
className={cn("min-w-0 flex-1 text-left", multiSelectKeyDown && "cursor-copy")}
|
||||
className={cn("min-w-0 flex-1 px-4 py-4 text-left", multiSelectKeyDown && "cursor-copy")}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
title="Ctrl/Cmd 点击切换选择"
|
||||
>
|
||||
<div className="mb-1 truncate text-sm font-semibold text-slate-950">{memo.title || "Untitled memo"}</div>
|
||||
<div className="line-clamp-2 min-h-[40px] text-sm leading-5 text-slate-600">{memo.excerpt || "Empty memo"}</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<time className="text-xs text-slate-400">{formatDateTime(memo.updatedAt)}</time>
|
||||
<div className="mb-2 truncate text-base font-semibold leading-6 text-slate-900">{memoTitle}</div>
|
||||
<div className="line-clamp-2 min-h-10 text-[15px] leading-5 text-slate-600">{memoExcerpt}</div>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<time className="text-sm text-slate-500">{formatMemoPreviewDate(memo.updatedAt)}</time>
|
||||
{memo.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="rounded bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700">
|
||||
<span key={tag} className="rounded-sm bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -1227,6 +1427,34 @@ const MemoCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getMemoTitle = (title: string | null | undefined) => title?.trim() || DEFAULT_MEMO_TITLE;
|
||||
|
||||
const formatMemoPreviewDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const memoDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
|
||||
if (memoDay === today) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
if (memoDay === today - 24 * 60 * 60 * 1000) {
|
||||
return "昨天";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
|
||||
|
||||
const getImageFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => {
|
||||
@@ -1662,7 +1890,7 @@ const RevisionHistoryDialog = ({
|
||||
<History className="h-4 w-4 text-emerald-700" />
|
||||
版本历史
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-500">{memo.title || "Untitled memo"}</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-500">{getMemoTitle(memo.title)}</div>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" title="关闭" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -1740,7 +1968,7 @@ const RevisionPreview = ({ title, markdown }: { title: string; markdown: string
|
||||
<div className="min-h-[260px] border-b border-emerald-100 p-4 sm:border-b-0 sm:border-r">
|
||||
<div className="mb-3 text-xs font-semibold uppercase text-slate-500">{title}</div>
|
||||
<pre className="max-h-[54dvh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-emerald-100 bg-emerald-50/30 p-3 text-sm leading-6 text-slate-700">
|
||||
{markdown || "Empty memo"}
|
||||
{markdown || "空笔记"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -1772,6 +2000,32 @@ const formatRevisionActor = (actor: string) => {
|
||||
return actor || "system";
|
||||
};
|
||||
|
||||
const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => {
|
||||
if (status === "conflict") {
|
||||
return "conflict";
|
||||
}
|
||||
|
||||
if (status === "syncing") {
|
||||
return "saving";
|
||||
}
|
||||
|
||||
return "queued";
|
||||
};
|
||||
|
||||
class MemoSaveRequestError extends Error {
|
||||
originalError: unknown;
|
||||
payload: MemoUpdateSyncPayload;
|
||||
tagsText: string;
|
||||
|
||||
constructor(originalError: unknown, payload: MemoUpdateSyncPayload, tagsText: string) {
|
||||
super(originalError instanceof Error ? originalError.message : "Memo save failed");
|
||||
this.name = "MemoSaveRequestError";
|
||||
this.originalError = originalError;
|
||||
this.payload = payload;
|
||||
this.tagsText = tagsText;
|
||||
}
|
||||
}
|
||||
|
||||
const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; readOnly: boolean }) => {
|
||||
const disabled = readOnly || !editor;
|
||||
const blockValue = getActiveBlockValue(editor);
|
||||
@@ -2002,7 +2256,7 @@ const EditorPane = ({
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
const [tagsText, setTagsText] = useState("");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error" | "conflict">("idle");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "queued" | "error" | "conflict">("idle");
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [dirtyVersion, setDirtyVersion] = useState(0);
|
||||
const [, setEditorStateVersion] = useState(0);
|
||||
@@ -2181,6 +2435,7 @@ const EditorPane = ({
|
||||
|
||||
useEffect(() => {
|
||||
const currentEditor = editorRef.current;
|
||||
let cancelled = false;
|
||||
|
||||
if (!memo) {
|
||||
memoRef.current = null;
|
||||
@@ -2202,21 +2457,46 @@ const EditorPane = ({
|
||||
return;
|
||||
}
|
||||
|
||||
hydratingRef.current = true;
|
||||
editingMemoIdRef.current = memo.id;
|
||||
hasUnsavedChangesRef.current = false;
|
||||
setHasUnsavedChanges(false);
|
||||
setSaveState("idle");
|
||||
setTitle(memo.title ?? "");
|
||||
setTagsText(memo.tags.join(", "));
|
||||
void (async () => {
|
||||
const [draft, queuedUpdate] = memo.isDeleted
|
||||
? [null, null]
|
||||
: await Promise.all([
|
||||
localDb.drafts.get(memo.id),
|
||||
localDb.syncQueue.get(getMemoUpdateQueueId(memo.id)),
|
||||
]);
|
||||
|
||||
if (currentEditor) {
|
||||
currentEditor.commands.setContent(memo.contentJson);
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
hydratingRef.current = false;
|
||||
}, 0);
|
||||
const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0;
|
||||
const remoteUpdatedAt = Date.parse(memo.updatedAt);
|
||||
const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt));
|
||||
const nextTitle = useDraft && draft ? draft.title : memo.title ?? "";
|
||||
const nextTagsText = useDraft && draft ? draft.tagsText : memo.tags.join(", ");
|
||||
const nextContent = useDraft && draft ? draft.contentJson : memo.contentJson;
|
||||
const nextHasUnsavedChanges = Boolean(useDraft && !queuedUpdate);
|
||||
|
||||
hydratingRef.current = true;
|
||||
editingMemoIdRef.current = memo.id;
|
||||
hasUnsavedChangesRef.current = nextHasUnsavedChanges;
|
||||
setHasUnsavedChanges(nextHasUnsavedChanges);
|
||||
setSaveState(queuedUpdate ? syncStatusToSaveState(queuedUpdate.status) : "idle");
|
||||
setTitle(nextTitle);
|
||||
setTagsText(nextTagsText);
|
||||
|
||||
if (currentEditor) {
|
||||
currentEditor.commands.setContent(nextContent);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
hydratingRef.current = false;
|
||||
}, 0);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isTrashView, memo, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2258,12 +2538,26 @@ const EditorPane = ({
|
||||
throw new Error("Editor is not ready");
|
||||
}
|
||||
|
||||
const data = await api.updateMemo(currentMemo.id, {
|
||||
const contentJson = currentEditor.getJSON() as TiptapDoc;
|
||||
const payload: MemoUpdateSyncPayload = {
|
||||
memoId: currentMemo.id,
|
||||
expectedRevision: currentMemo.revision,
|
||||
title,
|
||||
contentJson: currentEditor.getJSON() as TiptapDoc,
|
||||
contentJson,
|
||||
tags: parseTagsText(tagsText),
|
||||
});
|
||||
};
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = await api.updateMemo(currentMemo.id, {
|
||||
expectedRevision: payload.expectedRevision,
|
||||
title: payload.title,
|
||||
contentJson: payload.contentJson,
|
||||
tags: payload.tags,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new MemoSaveRequestError(error, payload, tagsText);
|
||||
}
|
||||
|
||||
return { memo: data.memo, snapshot };
|
||||
},
|
||||
@@ -2286,9 +2580,35 @@ const EditorPane = ({
|
||||
setHasUnsavedChanges(true);
|
||||
setSaveState("idle");
|
||||
},
|
||||
onError: (error) => {
|
||||
const code = error && typeof error === "object" && "code" in error ? String(error.code) : null;
|
||||
setSaveState(code === "revision_conflict" ? "conflict" : "error");
|
||||
onError: async (error) => {
|
||||
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
|
||||
const code =
|
||||
sourceError && typeof sourceError === "object" && "code" in sourceError
|
||||
? String(sourceError.code)
|
||||
: null;
|
||||
|
||||
if (code === "revision_conflict") {
|
||||
setSaveState("conflict");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
|
||||
await queueMemoUpdate(error.payload);
|
||||
await localDb.drafts.put({
|
||||
memoId: error.payload.memoId,
|
||||
title: error.payload.title,
|
||||
tagsText: error.tagsText,
|
||||
contentJson: error.payload.contentJson,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
hasUnsavedChangesRef.current = false;
|
||||
setHasUnsavedChanges(false);
|
||||
setSaveState("queued");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveState("error");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2332,13 +2652,15 @@ const EditorPane = ({
|
||||
? "保存中"
|
||||
: saveState === "saved"
|
||||
? "已保存"
|
||||
: saveState === "conflict"
|
||||
? "有冲突"
|
||||
: saveState === "error"
|
||||
? "保存失败"
|
||||
: hasUnsavedChanges
|
||||
? "未保存"
|
||||
: "已保存";
|
||||
: saveState === "queued"
|
||||
? "待同步"
|
||||
: saveState === "conflict"
|
||||
? "有冲突"
|
||||
: saveState === "error"
|
||||
? "保存失败"
|
||||
: hasUnsavedChanges
|
||||
? "未保存"
|
||||
: "已保存";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
@@ -2437,7 +2759,7 @@ const EditorPane = ({
|
||||
markDirty();
|
||||
}}
|
||||
className="block w-full border-0 bg-transparent text-2xl font-semibold leading-tight text-slate-950 outline-none placeholder:text-slate-300 sm:text-3xl"
|
||||
placeholder="Untitled memo"
|
||||
placeholder={DEFAULT_MEMO_TITLE}
|
||||
/>
|
||||
<label className="flex h-8 items-center gap-2 text-sm text-slate-500">
|
||||
<Tags className="h-4 w-4" />
|
||||
|
||||
@@ -9,14 +9,40 @@ export type LocalDraft = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MemoUpdateSyncPayload = {
|
||||
memoId: string;
|
||||
expectedRevision: number;
|
||||
title: string;
|
||||
contentJson: TiptapDoc;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type SyncQueueItem = {
|
||||
id: string;
|
||||
kind: "memo.update";
|
||||
memoId: string;
|
||||
status: "pending" | "syncing" | "conflict" | "error";
|
||||
payload: MemoUpdateSyncPayload;
|
||||
attemptCount: number;
|
||||
lastError: string | null;
|
||||
nextAttemptAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
class EdgeEverLocalDb extends Dexie {
|
||||
drafts!: Table<LocalDraft, string>;
|
||||
syncQueue!: Table<SyncQueueItem, string>;
|
||||
|
||||
constructor() {
|
||||
super("edgeever-local");
|
||||
this.version(1).stores({
|
||||
drafts: "memoId, updatedAt",
|
||||
});
|
||||
this.version(2).stores({
|
||||
drafts: "memoId, updatedAt",
|
||||
syncQueue: "id, memoId, status, updatedAt, nextAttemptAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { MemoDetail, TiptapDoc } from "@edgeever/shared";
|
||||
import { liveQuery } from "dexie";
|
||||
import { ApiRequestError, api } from "@/lib/api";
|
||||
import { localDb, type MemoUpdateSyncPayload, type SyncQueueItem } from "@/lib/local-db";
|
||||
|
||||
export type SyncQueueSummary = {
|
||||
total: number;
|
||||
pending: number;
|
||||
syncing: number;
|
||||
conflict: number;
|
||||
error: number;
|
||||
};
|
||||
|
||||
export type SyncRunResult = {
|
||||
attempted: number;
|
||||
synced: number;
|
||||
failed: number;
|
||||
conflicted: number;
|
||||
};
|
||||
|
||||
export const emptySyncQueueSummary = (): SyncQueueSummary => ({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
syncing: 0,
|
||||
conflict: 0,
|
||||
error: 0,
|
||||
});
|
||||
|
||||
export const getMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`;
|
||||
|
||||
export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload) => {
|
||||
const id = getMemoUpdateQueueId(payload.memoId);
|
||||
const now = new Date().toISOString();
|
||||
const existing = await localDb.syncQueue.get(id);
|
||||
|
||||
await localDb.syncQueue.put({
|
||||
id,
|
||||
kind: "memo.update",
|
||||
memoId: payload.memoId,
|
||||
status: "pending",
|
||||
payload,
|
||||
attemptCount: existing?.attemptCount ?? 0,
|
||||
lastError: null,
|
||||
nextAttemptAt: null,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
|
||||
export const observeSyncQueue = (onChange: (summary: SyncQueueSummary) => void) => {
|
||||
const subscription = liveQuery(async () => summarizeSyncQueue(await localDb.syncQueue.toArray())).subscribe({
|
||||
next: onChange,
|
||||
error: () => onChange(emptySyncQueueSummary()),
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
};
|
||||
|
||||
export const syncQueuedChanges = async (options: {
|
||||
onSynced?: (memo: MemoDetail) => void | Promise<void>;
|
||||
} = {}): Promise<SyncRunResult> => {
|
||||
const result: SyncRunResult = {
|
||||
attempted: 0,
|
||||
synced: 0,
|
||||
failed: 0,
|
||||
conflicted: 0,
|
||||
};
|
||||
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const items = (await localDb.syncQueue.where("status").anyOf("pending", "error").toArray())
|
||||
.filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now)
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
|
||||
for (const item of items) {
|
||||
result.attempted += 1;
|
||||
await localDb.syncQueue.update(item.id, {
|
||||
status: "syncing",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const memo = await syncQueueItem(item);
|
||||
await localDb.syncQueue.delete(item.id);
|
||||
await localDb.drafts.delete(item.memoId);
|
||||
await options.onSynced?.(memo);
|
||||
result.synced += 1;
|
||||
} catch (error) {
|
||||
const status = isRevisionConflict(error) ? "conflict" : "error";
|
||||
const attemptCount = item.attemptCount + 1;
|
||||
|
||||
await localDb.syncQueue.update(item.id, {
|
||||
status,
|
||||
attemptCount,
|
||||
lastError: getErrorMessage(error),
|
||||
nextAttemptAt: status === "error" ? nextRetryAt(attemptCount) : null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (status === "conflict") {
|
||||
result.conflicted += 1;
|
||||
} else {
|
||||
result.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const shouldQueueMemoSaveError = (error: unknown) => {
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error instanceof ApiRequestError) {
|
||||
return error.status === 408 || error.status === 429 || error.status >= 500;
|
||||
}
|
||||
|
||||
return error instanceof TypeError;
|
||||
};
|
||||
|
||||
const syncQueueItem = async (item: SyncQueueItem): Promise<MemoDetail> => {
|
||||
if (item.kind !== "memo.update") {
|
||||
throw new Error(`Unsupported sync item kind: ${item.kind}`);
|
||||
}
|
||||
|
||||
const data = await api.updateMemo(item.memoId, {
|
||||
expectedRevision: item.payload.expectedRevision,
|
||||
title: item.payload.title,
|
||||
contentJson: item.payload.contentJson as TiptapDoc,
|
||||
tags: item.payload.tags,
|
||||
});
|
||||
|
||||
return data.memo;
|
||||
};
|
||||
|
||||
const summarizeSyncQueue = (items: SyncQueueItem[]): SyncQueueSummary =>
|
||||
items.reduce(
|
||||
(summary, item) => {
|
||||
summary.total += 1;
|
||||
summary[item.status] += 1;
|
||||
return summary;
|
||||
},
|
||||
emptySyncQueueSummary()
|
||||
);
|
||||
|
||||
const isRevisionConflict = (error: unknown) =>
|
||||
error instanceof ApiRequestError && error.code === "revision_conflict";
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return "Sync failed";
|
||||
};
|
||||
|
||||
const nextRetryAt = (attemptCount: number) => {
|
||||
const delayMs = Math.min(5 * 60_000, 2 ** Math.min(attemptCount, 6) * 1000);
|
||||
return new Date(Date.now() + delayMs).toISOString();
|
||||
};
|
||||
+1463
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@
|
||||
"db:migrate:remote": "bun scripts/run-wrangler.mjs d1 migrations apply DB --remote",
|
||||
"auth:hash": "bun scripts/hash-password.mjs",
|
||||
"cli": "bun scripts/edgeever.mjs",
|
||||
"mcp:stdio": "bun scripts/edgeever-mcp-stdio.mjs",
|
||||
"deploy": "bun run build && bun run db:migrate:remote && bun scripts/run-wrangler.mjs deploy",
|
||||
"deploy:tianma": "EDGE_EVER_INSTANCE=tianma bun run deploy",
|
||||
"deploy:demo": "EDGE_EVER_INSTANCE=demo bun run deploy",
|
||||
|
||||
@@ -14,6 +14,8 @@ export type TiptapDoc = {
|
||||
content: TiptapNode[];
|
||||
};
|
||||
|
||||
export const DEFAULT_MEMO_TITLE = "无标题笔记";
|
||||
|
||||
export const emptyDoc = (): TiptapDoc => ({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const CONFIG_PATH = process.env.EDGEEVER_CONFIG || join(homedir(), ".edgeever", "config.json");
|
||||
const DEFAULT_URL = "http://127.0.0.1:8787";
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
|
||||
const usage = `EdgeEver MCP stdio adapter
|
||||
|
||||
Usage:
|
||||
EDGEEVER_URL=https://your.edgeever.host EDGEEVER_TOKEN=... bun scripts/edgeever-mcp-stdio.mjs
|
||||
bun scripts/edgeever-mcp-stdio.mjs --profile prod
|
||||
|
||||
The adapter bridges local stdio MCP clients to the remote EdgeEver /mcp endpoint.
|
||||
`;
|
||||
|
||||
if (options.help || options.h) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let client;
|
||||
|
||||
try {
|
||||
client = await createClient(options.profile);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let inputBuffer = Buffer.alloc(0);
|
||||
let transportMode = null;
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
||||
drainInput();
|
||||
});
|
||||
|
||||
process.stdin.on("error", (error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.stdin.resume();
|
||||
|
||||
function drainInput() {
|
||||
while (inputBuffer.length > 0) {
|
||||
const framed = readFramedMessage(inputBuffer);
|
||||
|
||||
if (framed) {
|
||||
transportMode = "framed";
|
||||
inputBuffer = inputBuffer.subarray(framed.consumedBytes);
|
||||
void handleMessage(framed.payload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (looksLikeFramedMessage(inputBuffer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newlineIndex = inputBuffer.indexOf(0x0a);
|
||||
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const line = inputBuffer.subarray(0, newlineIndex).toString("utf8").trim();
|
||||
inputBuffer = inputBuffer.subarray(newlineIndex + 1);
|
||||
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
transportMode = transportMode || "line";
|
||||
void handleMessage(line);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(payload) {
|
||||
let request;
|
||||
|
||||
try {
|
||||
request = JSON.parse(payload);
|
||||
} catch {
|
||||
writeMessage(jsonRpcError(null, -32700, "Parse error"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await forwardToEdgeEver(request);
|
||||
|
||||
if (response !== null) {
|
||||
writeMessage(response);
|
||||
}
|
||||
} catch (error) {
|
||||
writeMessage(
|
||||
jsonRpcError(getJsonRpcId(request), -32000, error instanceof Error ? error.message : "MCP bridge failed")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToEdgeEver(request) {
|
||||
const response = await fetch(`${client.baseUrl}/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createClient(profileName) {
|
||||
const config = await readConfig();
|
||||
const profile = profileName ? config.profiles?.[profileName] : undefined;
|
||||
const baseUrl = (process.env.EDGEEVER_URL || profile?.url || DEFAULT_URL).replace(/\/+$/, "");
|
||||
const token = process.env.EDGEEVER_TOKEN || profile?.token;
|
||||
|
||||
if (!token) {
|
||||
throw new Error("EDGEEVER_TOKEN is required, or configure a profile with `bun run cli -- profile set`.");
|
||||
}
|
||||
|
||||
return { baseUrl, token };
|
||||
}
|
||||
|
||||
async function readConfig() {
|
||||
try {
|
||||
const value = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
||||
return value && typeof value === "object" ? value : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readFramedMessage(buffer) {
|
||||
const headerEnd = findHeaderEnd(buffer);
|
||||
|
||||
if (!headerEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = buffer.subarray(0, headerEnd.index).toString("utf8");
|
||||
const match = /^content-length:\s*(\d+)\s*$/im.exec(header);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentLength = Number(match[1]);
|
||||
const bodyStart = headerEnd.index + headerEnd.length;
|
||||
const bodyEnd = bodyStart + contentLength;
|
||||
|
||||
if (!Number.isInteger(contentLength) || contentLength < 0 || buffer.length < bodyEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
payload: buffer.subarray(bodyStart, bodyEnd).toString("utf8"),
|
||||
consumedBytes: bodyEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function findHeaderEnd(buffer) {
|
||||
const crlf = buffer.indexOf("\r\n\r\n");
|
||||
|
||||
if (crlf !== -1) {
|
||||
return { index: crlf, length: 4 };
|
||||
}
|
||||
|
||||
const lf = buffer.indexOf("\n\n");
|
||||
return lf === -1 ? null : { index: lf, length: 2 };
|
||||
}
|
||||
|
||||
function looksLikeFramedMessage(buffer) {
|
||||
return buffer.subarray(0, Math.min(buffer.length, 32)).toString("utf8").toLowerCase().startsWith("content-length:");
|
||||
}
|
||||
|
||||
function writeMessage(message) {
|
||||
const payload = `${JSON.stringify(message)}\n`;
|
||||
|
||||
if (transportMode === "framed") {
|
||||
const content = payload.trimEnd();
|
||||
process.stdout.write(`Content-Length: ${Buffer.byteLength(content, "utf8")}\r\n\r\n${content}`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(payload);
|
||||
}
|
||||
|
||||
function parseOptions(args) {
|
||||
const parsed = {};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (!arg.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = arg.slice(2);
|
||||
const next = args[index + 1];
|
||||
|
||||
if (!next || next.startsWith("--")) {
|
||||
parsed[key] = "true";
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getJsonRpcId(request) {
|
||||
if (!request || typeof request !== "object" || !("id" in request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = request.id;
|
||||
return typeof id === "string" || typeof id === "number" || id === null ? id : null;
|
||||
}
|
||||
|
||||
function jsonRpcError(id, code, message) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user