From dd746e1d60dc4a21ad8c8c16dec2e336087ddfed Mon Sep 17 00:00:00 2001 From: tianma Date: Fri, 26 Jun 2026 17:02:35 +0800 Subject: [PATCH] Initial EdgeEver implementation --- .gitignore | 14 + README.md | 194 +++++++ apps/api/package.json | 6 + apps/api/src/index.ts | 604 +++++++++++++++++++ apps/api/tsconfig.json | 4 + apps/web/index.html | 25 + apps/web/package.json | 6 + apps/web/postcss.config.js | 6 + apps/web/public/manifest.webmanifest | 11 + apps/web/src/app/App.tsx | 804 ++++++++++++++++++++++++++ apps/web/src/components/ui/button.tsx | 36 ++ apps/web/src/lib/api.ts | 98 ++++ apps/web/src/lib/local-db.ts | 23 + apps/web/src/lib/utils.ts | 49 ++ apps/web/src/main.tsx | 29 + apps/web/src/styles/globals.css | 88 +++ apps/web/src/vite-env.d.ts | 1 + apps/web/tailwind.config.ts | 27 + apps/web/tsconfig.json | 10 + apps/web/vite.config.ts | 29 + bun.lock | 651 +++++++++++++++++++++ docs/project-structure.md | 92 +++ migrations/0001_initial.sql | 239 ++++++++ package.json | 53 ++ packages/shared/package.json | 10 + packages/shared/src/content.ts | 137 +++++ packages/shared/src/index.ts | 3 + packages/shared/src/schemas.ts | 40 ++ packages/shared/src/types.ts | 43 ++ packages/shared/tsconfig.json | 4 + tailwind.config.ts | 24 + tsconfig.json | 27 + wrangler.toml | 24 + 33 files changed, 3411 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/src/app/App.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/local-db.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/styles/globals.css create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 bun.lock create mode 100644 docs/project-structure.md create mode 100644 migrations/0001_initial.sql create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/content.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..79dd00eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +.env +.env.* +!.env.example + +node_modules/ +dist/ +build/ +coverage/ + +.wrangler/ +.dev.vars + +*.log diff --git a/README.md b/README.md new file mode 100644 index 00000000..e8f41622 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# EdgeEver + +> **EdgeEver: A self-hosted, Cloudflare-native Evernote alternative.** +> +> **EdgeEver:基于 Cloudflare 全家桶自托管的开源『印象笔记』。** + +EdgeEver 是一个完全开源、支持自部署、面向人类和 AI Agent 的现代笔记工作区。 + +它致敬经典印象笔记的大屏三栏交互:笔记本目录、笔记列表、主编辑区。但 EdgeEver 不想复刻臃肿的旧时代套件,而是用 Cloudflare 的边缘网络、轻量前端、开放 API、MCP 和 CLI,把笔记系统重新做成一个可自托管、可编程、可被 AI 读写的个人知识库底座。 + +## 产品定位 + +EdgeEver 是一个现代个人知识库,面向三类一等用户: + +- **人类用户**:在专注的三栏界面中捕获、整理、编辑、搜索、合并笔记。 +- **AI Agent**:通过 REST API、MCP 或 CLI 读取、搜索、新建、追加、合并和整理笔记,而不是模拟点击 UI。 +- **自部署用户**:把完整系统部署到自己的 Cloudflare 账号下,用很低的运维成本拥有自己的笔记后端。 + +EdgeEver 的目标不是再做一个大而全的效率套件,而是做一个锋利、轻快、开放的笔记核心:本地优先的体验,全球同步的部署,面向 AI 的明确接口。 + +## 项目背景 + +经典印象笔记有一个至今仍然优秀的设计:大屏三栏工作流。 + +```text +笔记本目录 -> 笔记列表 -> 编辑器 +``` + +这个布局依旧是浏览个人资料库、整理长期知识、快速切换上下文的高效方式。但现代笔记系统需要一个新的技术底座: + +- 笔记内容应该可读、可导出、可迁移。 +- 前端应该是轻量 SPA/PWA,而不是不必要的重型服务端应用。 +- 图片和附件应该进入对象存储,而不是塞进不透明的数据块。 +- 搜索应该建立在清晰的数据模型之上。 +- AI Agent 应该拥有显式、受权限控制的接口,而不是伪装成人类用户。 +- 自托管应该对个人开发者足够现实。 + +EdgeEver 要做的就是这个版本:经典的信息架构,现代的边缘基础设施,从第一天起就为 Agent 读写做好准备。 + +## 核心体验 + +- **桌面三栏布局**:笔记本树、笔记卡片流、富文本编辑器。 +- **无限级嵌套笔记本**:通过 `parent_id` 支持多级目录树。 +- **Markdown 友好的富文本编辑**:TipTap/ProseMirror JSON 作为权威编辑器格式,Markdown 作为 Agent、CLI 和导出格式。 +- **PWA 响应式布局**:桌面端三栏,移动端自动折叠为单栏。 +- **多选合并笔记**:选中多条笔记后创建一条合并后的新笔记,用 Markdown 分割线连接内容,原笔记软删除,资源引用重新指向新笔记。 +- **Headless 能力**:REST、MCP 和 CLI 共享同一套核心服务。 +- **资源模型**:图片和附件存入 R2,D1 保存资源元数据和笔记关联关系。 + +## 架构规划 + +```text +edgeever/ +├── apps/ +│ ├── web/ # Vite + React PWA +│ └── api/ # Cloudflare Worker + Hono +├── packages/ +│ └── shared/ # 共享类型、Zod schema、内容格式转换 +├── migrations/ # Cloudflare D1 SQL 迁移 +├── docs/ +├── tailwind.config.ts +└── wrangler.toml # Worker、Assets、D1、R2 绑定配置 +``` + +目标运行形态是一个 Cloudflare Worker: + +- `/api/v1/*` 提供 REST API。 +- `/mcp` 提供远程 MCP Streamable HTTP 入口。 +- Workers Assets 从 `apps/web/dist` 托管构建后的 SPA。 +- D1 存储笔记本、笔记元数据、正文记录、搜索索引、Token、修订记录和审计事件。 +- R2 存储图片和附件。 + +## 技术栈 + +### 工程工具链 + +- **Bun**:包管理器、脚本运行入口和 monorepo workspace 工具。 + +### 前端 + +- **Vite**:轻量 SPA 构建工具。 +- **React**:应用 UI 层。 +- **Tailwind CSS**:原子化样式系统。 +- **shadcn/ui**:可组合、可访问的 UI 组件基础。 +- **TipTap**:基于 ProseMirror 的富文本编辑器核心。 +- **TanStack Query**:API 缓存、乐观更新、重试和服务端状态管理。 +- **Dexie**:基于 IndexedDB 的离线缓存和本地草稿队列。 +- **Zod**:前后端共享数据校验。 + +### 后端 + +- **Cloudflare Workers**:边缘运行时。 +- **Hono**:轻量、快速的 HTTP 路由框架。 +- **Cloudflare D1**:SQLite 语义的关系型数据库。 +- **Cloudflare R2**:图片和附件对象存储。 +- **D1 FTS5**:全文搜索能力。 +- **Wrangler**:本地开发、迁移和部署工具。 + +### Agent 与开发者接口 + +- **REST API**:供 Web、CLI 和第三方集成调用。 +- **MCP Streamable HTTP**:通过 `/mcp` 暴露给支持远程 MCP 的客户端。 +- **本地 stdio MCP Server**:通过 EdgeEver CLI 启动,供桌面 AI 工具接入。 +- **CLI**:用于脚本化、导入导出和终端工作流。 + +## 内容存储策略 + +EdgeEver 不应该只把 Markdown 当作唯一真实格式。 + +```text +content_json TipTap/ProseMirror 文档,权威编辑器格式 +content_markdown Agent、CLI、导入导出、diff 使用的格式 +content_text 全文搜索、摘要、embedding 使用的纯文本 +``` + +浏览器编辑器写入 TipTap JSON,并派生 Markdown 和纯文本。MCP 与 CLI 可以接受 Markdown 输入,但服务端需要把 Markdown 转换并校验为权威的 TipTap JSON 后再入库。 + +## Cloudflare 初始化 + +创建 D1 数据库和 R2 存储桶: + +```sh +bunx wrangler d1 create edgeever +bunx wrangler r2 bucket create edgeever-resources +``` + +把 D1 创建命令返回的 `database_id` 填入 `wrangler.toml`。 + +应用本地迁移: + +```sh +bunx wrangler d1 migrations apply DB --local +``` + +应用远程迁移: + +```sh +bunx wrangler d1 migrations apply DB --remote +``` + +## 本地开发 + +安装依赖: + +```sh +bun install +``` + +应用本地 D1 迁移: + +```sh +bun run db:migrate:local +``` + +同时启动 Worker API 和 Vite 前端: + +```sh +bun run dev +``` + +也可以分开启动: + +```sh +bun run dev:api +bun run dev:web +``` + +常用校验: + +```sh +bun run typecheck +bun run build +``` + +## 当前阶段 + +当前已经具备第一条可运行纵向切片: + +- Bun monorepo 基础工程。 +- D1 初始 schema:支持嵌套笔记本、笔记正文拆表、FTS5 搜索、资源、修订、审计、软删除和合并字段。 +- Hono REST API:笔记本列表/创建/更新/删除,笔记列表/创建/读取/更新/删除,搜索,多选合并。 +- Vite React PWA:桌面三栏布局,移动端单栏切换,递归笔记本树,笔记卡片流,TipTap 编辑器,标签和本地草稿备份。 +- 基础 `wrangler.toml`:配置 Workers Assets、D1、R2 和 `/api/*`、`/mcp` Worker-first 路由。 + +后续阶段会继续补完整 MCP Server、CLI、资源上传、认证 Token、导入导出和更完整的 Markdown 双向转换。 + +## 项目原则 + +- **默认开放**:数据应该可读、可导出、可脚本化。 +- **为 Agent 准备,但不牺牲人类体验**:人类使用最好的 UI,Agent 使用明确的 API。 +- **边缘原生,而不是边缘噱头**:只在 Cloudflare 真正降低部署和运维复杂度的地方使用它。 +- **内容可迁移**:Markdown 很重要,但结构化编辑器 JSON 才能保护富文本保真度。 +- **软删除和可审计**:破坏性操作必须可追踪、可恢复。 +- **小模块,清边界**:UI、REST、MCP、CLI 共享核心行为,而不是重复实现。 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000..73545166 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,6 @@ +{ + "name": "@edgeever/api", + "version": "0.1.0", + "private": true, + "type": "module" +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 00000000..1238a1b7 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,604 @@ +import { + createExcerpt, + docToMarkdown, + docToText, + emptyDoc, + markdownToDoc, + MemoCreateSchema, + MemoUpdateSchema, + MergeMemosSchema, + normalizeTags, + NotebookCreateSchema, + NotebookUpdateSchema, + type MemoDetail, + type MemoSummary, + type Notebook, + type TiptapDoc, +} from "@edgeever/shared"; +import { zValidator } from "@hono/zod-validator"; +import type { Context } from "hono"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; + +type Bindings = { + DB: D1Database; + RESOURCES: R2Bucket; +}; + +type NotebookRow = { + id: string; + parent_id: string | null; + name: string; + slug: string | null; + icon: string | null; + color: string | null; + sort_order: number; + created_at: string; + updated_at: string; +}; + +type MemoSummaryRow = { + id: string; + notebook_id: string; + title: string | null; + excerpt: string; + tags_json: string; + is_pinned: number; + is_archived: number; + created_at: string; + updated_at: string; + revision: number; +}; + +type MemoDetailRow = MemoSummaryRow & { + content_json: string; + content_markdown: string; + content_text: string; + source_memo_ids: string; + merge_source_count: number; + merged_into_memo_id: string | null; + content_hash: string; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +app.use( + "/api/*", + cors({ + origin: ["http://127.0.0.1:5173", "http://localhost:5173"], + allowHeaders: ["Content-Type", "Authorization"], + allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + }) +); + +app.get("/api/health", (c) => + c.json({ + ok: true, + name: "edgeever", + runtime: "cloudflare-workers", + }) +); + +app.get("/api/v1/notebooks", async (c) => { + const rows = await c.env.DB.prepare( + `SELECT id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at + FROM notebooks + WHERE is_deleted = 0 + ORDER BY parent_id IS NOT NULL, sort_order ASC, name ASC` + ).all(); + + return c.json({ notebooks: rows.results.map(mapNotebook) }); +}); + +app.post("/api/v1/notebooks", zValidator("json", NotebookCreateSchema), async (c) => { + const input = c.req.valid("json"); + const id = createId("nb"); + const now = isoNow(); + + await c.env.DB.prepare( + `INSERT INTO notebooks (id, parent_id, name, slug, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind(id, input.parentId ?? null, input.name, slugify(input.name), Date.now(), now, now) + .run(); + + const notebook = await getNotebook(c.env.DB, id); + await audit(c.env.DB, "user", null, "notebook.create", "notebook", id, { name: input.name }); + + return c.json({ notebook }, 201); +}); + +app.patch("/api/v1/notebooks/:id", zValidator("json", NotebookUpdateSchema), async (c) => { + const id = c.req.param("id"); + const input = c.req.valid("json"); + const current = await getNotebook(c.env.DB, id); + + if (!current) { + return notFound(c, "Notebook not found"); + } + + const nextName = input.name ?? current.name; + const nextParentId = input.parentId === undefined ? current.parentId : input.parentId; + const nextSortOrder = input.sortOrder ?? current.sortOrder; + const now = isoNow(); + + await c.env.DB.prepare( + `UPDATE notebooks + SET name = ?, slug = ?, parent_id = ?, sort_order = ?, updated_at = ? + WHERE id = ? AND is_deleted = 0` + ) + .bind(nextName, slugify(nextName), nextParentId ?? null, nextSortOrder, now, id) + .run(); + + await audit(c.env.DB, "user", null, "notebook.update", "notebook", id, input); + return c.json({ notebook: await getNotebook(c.env.DB, id) }); +}); + +app.delete("/api/v1/notebooks/:id", async (c) => { + const id = c.req.param("id"); + const now = isoNow(); + + await c.env.DB.prepare( + `UPDATE notebooks + SET is_deleted = 1, deleted_at = ?, updated_at = ? + WHERE id = ? AND id <> 'nb_inbox'` + ) + .bind(now, now, id) + .run(); + + await audit(c.env.DB, "user", null, "notebook.delete", "notebook", id, {}); + return c.json({ ok: true }); +}); + +app.get("/api/v1/memos", async (c) => { + const notebookId = c.req.query("notebookId"); + const q = c.req.query("q")?.trim(); + const limit = clampNumber(Number(c.req.query("limit") ?? 80), 1, 100); + + if (q) { + const ftsQuery = toFtsQuery(q); + + if (ftsQuery) { + const rows = await c.env.DB.prepare( + `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.created_at, m.updated_at, c.revision + FROM memos_fts f + INNER JOIN memos m ON m.id = f.memo_id + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE memos_fts MATCH ? + AND m.is_deleted = 0 + AND (? IS NULL OR m.notebook_id = ?) + ORDER BY m.is_pinned DESC, m.updated_at DESC + LIMIT ?` + ) + .bind(ftsQuery, notebookId ?? null, notebookId ?? null, limit) + .all(); + + return c.json({ memos: rows.results.map(mapMemoSummary) }); + } + } + + const rows = await c.env.DB.prepare( + `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.created_at, m.updated_at, c.revision + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.is_deleted = 0 + AND (? IS NULL OR m.notebook_id = ?) + ORDER BY m.is_pinned DESC, m.updated_at DESC + LIMIT ?` + ) + .bind(notebookId ?? null, notebookId ?? null, limit) + .all(); + + return c.json({ memos: rows.results.map(mapMemoSummary) }); +}); + +app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => { + const input = c.req.valid("json"); + const tags = normalizeTags(input.tags); + const contentMarkdown = input.contentMarkdown ?? ""; + const contentJson = markdownToDoc(contentMarkdown); + const contentText = docToText(contentJson); + const title = input.title || deriveTitle(contentText); + const excerpt = createExcerpt(contentText || title || ""); + const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); + const id = createId("memo"); + const now = isoNow(); + + await c.env.DB.batch([ + c.env.DB.prepare( + `INSERT INTO memos ( + id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'user', 'user', ?, ?)` + ).bind(id, input.notebookId, title, excerpt, JSON.stringify(tags), now, now), + c.env.DB.prepare( + `INSERT INTO memo_contents ( + memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?)` + ).bind(id, JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, now, now), + c.env.DB.prepare( + `INSERT INTO memos_fts (memo_id, title, content_text, tags) + VALUES (?, ?, ?, ?)` + ).bind(id, title, contentText, tags.join(" ")), + auditStatement(c.env.DB, "user", null, "memo.create", "memo", id, { notebookId: input.notebookId }), + ]); + + return c.json({ memo: await getMemoDetail(c.env.DB, id) }, 201); +}); + +app.get("/api/v1/memos/:id", async (c) => { + const memo = await getMemoDetail(c.env.DB, c.req.param("id")); + + if (!memo) { + return notFound(c, "Memo not found"); + } + + return c.json({ memo }); +}); + +app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) => { + const id = c.req.param("id"); + const input = c.req.valid("json"); + const current = await getMemoDetailRow(c.env.DB, id); + + if (!current) { + return notFound(c, "Memo not found"); + } + + if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) { + return c.json( + { + error: { + code: "revision_conflict", + message: "Memo was updated elsewhere. Reload before saving.", + details: { + expectedRevision: input.expectedRevision, + currentRevision: current.revision, + }, + }, + }, + 409 + ); + } + + const currentContentJson = JSON.parse(current.content_json) as TiptapDoc; + const contentJson = input.contentJson + ? (input.contentJson as TiptapDoc) + : input.contentMarkdown !== undefined + ? markdownToDoc(input.contentMarkdown) + : currentContentJson; + const contentMarkdown = + input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson); + const contentText = docToText(contentJson); + const title = input.title ?? current.title ?? deriveTitle(contentText); + const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags); + const excerpt = createExcerpt(contentText || title || ""); + const notebookId = input.notebookId ?? current.notebook_id; + const nextRevision = current.revision + 1; + const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); + const now = isoNow(); + + await c.env.DB.batch([ + c.env.DB.prepare( + `INSERT INTO memo_revisions ( + id, memo_id, revision, title, content_json, content_markdown, content_hash, created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'user', ?)` + ).bind( + createId("rev"), + id, + current.revision, + current.title, + current.content_json, + current.content_markdown, + current.content_hash, + now + ), + c.env.DB.prepare( + `UPDATE memos + SET notebook_id = ?, title = ?, excerpt = ?, tags_json = ?, updated_by = 'user', updated_at = ? + WHERE id = ? AND is_deleted = 0` + ).bind(notebookId, title, excerpt, JSON.stringify(tags), now, id), + c.env.DB.prepare( + `UPDATE memo_contents + SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?, + revision = ?, updated_at = ? + WHERE memo_id = ?` + ).bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, now, id), + c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id), + c.env.DB.prepare( + `INSERT INTO memos_fts (memo_id, title, content_text, tags) + VALUES (?, ?, ?, ?)` + ).bind(id, title, contentText, tags.join(" ")), + auditStatement(c.env.DB, "user", null, "memo.update", "memo", id, { revision: nextRevision }), + ]); + + return c.json({ memo: await getMemoDetail(c.env.DB, id) }); +}); + +app.delete("/api/v1/memos/:id", async (c) => { + const id = c.req.param("id"); + const now = isoNow(); + + await c.env.DB.batch([ + c.env.DB.prepare( + `UPDATE memos + SET is_deleted = 1, deleted_at = ?, updated_at = ? + WHERE id = ? AND is_deleted = 0` + ).bind(now, now, id), + auditStatement(c.env.DB, "user", null, "memo.delete", "memo", id, {}), + ]); + + return c.json({ ok: true }); +}); + +app.post("/api/v1/memos/merge", zValidator("json", MergeMemosSchema), async (c) => { + const input = c.req.valid("json"); + const uniqueMemoIds = Array.from(new Set(input.memoIds)); + const placeholders = uniqueMemoIds.map(() => "?").join(", "); + const rows = await c.env.DB.prepare( + `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.created_at, m.updated_at, c.revision, + c.content_json, c.content_markdown, c.content_text, c.content_hash, + m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.is_deleted = 0 AND m.id IN (${placeholders})` + ) + .bind(...uniqueMemoIds) + .all(); + + if (rows.results.length !== uniqueMemoIds.length) { + return c.json( + { + error: { + code: "missing_memos", + message: "One or more memos cannot be merged.", + }, + }, + 400 + ); + } + + const ordered = uniqueMemoIds + .map((memoId) => rows.results.find((row) => row.id === memoId)) + .filter((row): row is MemoDetailRow => Boolean(row)); + const notebookId = input.notebookId ?? ordered[0].notebook_id; + const title = input.title || `合并笔记 ${new Date().toLocaleDateString("zh-CN")}`; + const mergedMarkdown = ordered.map((memo) => memo.content_markdown).join("\n\n---\n\n"); + const contentJson = markdownToDoc(mergedMarkdown); + const contentText = docToText(contentJson); + const tags = Array.from(new Set(ordered.flatMap((memo) => parseJsonArray(memo.tags_json)))); + const excerpt = createExcerpt(contentText || title); + const contentHash = await sha256(mergedMarkdown + JSON.stringify(contentJson)); + const newMemoId = createId("memo"); + const now = isoNow(); + + await c.env.DB.batch([ + c.env.DB.prepare( + `INSERT INTO memos ( + id, notebook_id, title, excerpt, tags_json, source_memo_ids, merge_source_count, + created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'user', 'user', ?, ?)` + ).bind( + newMemoId, + notebookId, + title, + excerpt, + JSON.stringify(tags), + JSON.stringify(uniqueMemoIds), + uniqueMemoIds.length, + now, + now + ), + c.env.DB.prepare( + `INSERT INTO memo_contents ( + memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?)` + ).bind(newMemoId, JSON.stringify(contentJson), mergedMarkdown, contentText, contentHash, now, now), + c.env.DB.prepare( + `INSERT INTO memos_fts (memo_id, title, content_text, tags) + VALUES (?, ?, ?, ?)` + ).bind(newMemoId, title, contentText, tags.join(" ")), + c.env.DB.prepare( + `UPDATE memos + SET is_deleted = 1, deleted_at = ?, merged_into_memo_id = ?, merged_at = ?, updated_at = ? + WHERE id IN (${placeholders})` + ).bind(now, newMemoId, now, now, ...uniqueMemoIds), + c.env.DB.prepare( + `UPDATE resources + SET original_memo_id = COALESCE(original_memo_id, memo_id), + memo_id = ?, + updated_at = ? + WHERE memo_id IN (${placeholders})` + ).bind(newMemoId, now, ...uniqueMemoIds), + auditStatement(c.env.DB, "user", null, "memo.merge", "memo", newMemoId, { + sourceMemoIds: uniqueMemoIds, + }), + ]); + + return c.json({ memo: await getMemoDetail(c.env.DB, newMemoId) }, 201); +}); + +app.all("/mcp", (c) => + c.json({ + name: "EdgeEver MCP endpoint", + status: "planned", + message: "Remote MCP will be wired to the same memo and notebook services as the REST API.", + restBasePath: "/api/v1", + }) +); + +app.notFound((c) => + c.json( + { + error: { + code: "not_found", + message: "Route not found", + }, + }, + 404 + ) +); + +export default app; + +const mapNotebook = (row: NotebookRow): Notebook => ({ + id: row.id, + parentId: row.parent_id, + name: row.name, + slug: row.slug, + icon: row.icon, + color: row.color, + sortOrder: row.sort_order, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({ + id: row.id, + notebookId: row.notebook_id, + title: row.title, + excerpt: row.excerpt, + tags: parseJsonArray(row.tags_json), + isPinned: Boolean(row.is_pinned), + isArchived: Boolean(row.is_archived), + revision: row.revision, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +const mapMemoDetail = (row: MemoDetailRow): MemoDetail => ({ + ...mapMemoSummary(row), + contentJson: parseDoc(row.content_json), + contentMarkdown: row.content_markdown, + contentText: row.content_text, + sourceMemoIds: parseJsonArray(row.source_memo_ids), + mergeSourceCount: row.merge_source_count, + mergedIntoMemoId: row.merged_into_memo_id, +}); + +const getNotebook = async (db: D1Database, id: string): Promise => { + const row = await db + .prepare( + `SELECT id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at + FROM notebooks + WHERE id = ? AND is_deleted = 0` + ) + .bind(id) + .first(); + + return row ? mapNotebook(row) : null; +}; + +const getMemoDetailRow = async (db: D1Database, id: string): Promise => + db + .prepare( + `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.created_at, m.updated_at, c.revision, + c.content_json, c.content_markdown, c.content_text, c.content_hash, + m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.id = ? AND m.is_deleted = 0` + ) + .bind(id) + .first(); + +const getMemoDetail = async (db: D1Database, id: string): Promise => { + const row = await getMemoDetailRow(db, id); + return row ? mapMemoDetail(row) : null; +}; + +const parseJsonArray = (json: string): string[] => { + try { + const value = JSON.parse(json); + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; + } catch { + return []; + } +}; + +const parseDoc = (json: string): TiptapDoc => { + try { + const value = JSON.parse(json); + return value && typeof value === "object" ? (value as TiptapDoc) : emptyDoc(); + } catch { + return emptyDoc(); + } +}; + +const audit = async ( + db: D1Database, + actorType: "user" | "agent" | "system", + actorId: string | null, + action: string, + entityType: string, + entityId: string, + metadata: unknown +) => auditStatement(db, actorType, actorId, action, entityType, entityId, metadata).run(); + +const auditStatement = ( + db: D1Database, + actorType: "user" | "agent" | "system", + actorId: string | null, + action: string, + entityType: string, + entityId: string, + metadata: unknown +) => + db + .prepare( + `INSERT INTO audit_events ( + id, actor_type, actor_id, action, entity_type, entity_id, metadata_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind(createId("audit"), actorType, actorId, action, entityType, entityId, JSON.stringify(metadata ?? {}), isoNow()); + +const createId = (prefix: string) => `${prefix}_${crypto.randomUUID().replace(/-/g, "")}`; + +const isoNow = () => new Date().toISOString(); + +const slugify = (value: string) => + value + .trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + +const deriveTitle = (text: string) => { + const title = text.trim().split(/\s+/).slice(0, 10).join(" "); + return title || "Untitled memo"; +}; + +const clampNumber = (value: number, min: number, max: number) => { + if (Number.isNaN(value)) { + return min; + } + + return Math.min(Math.max(value, min), max); +}; + +const toFtsQuery = (value: string) => { + const tokens = value.match(/[\p{L}\p{N}_-]+/gu) ?? []; + return tokens.slice(0, 8).join(" "); +}; + +const sha256 = async (value: string) => { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +const notFound = (c: Context, message: string) => + c.json( + { + error: { + code: "not_found", + message, + }, + }, + 404 + ); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 00000000..cae718b9 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + EdgeEver + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..ec1b26dc --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,6 @@ +{ + "name": "@edgeever/web", + "version": "0.1.0", + "private": true, + "type": "module" +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 00000000..f69f0771 --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "EdgeEver", + "short_name": "EdgeEver", + "description": "基于 Cloudflare 全家桶自托管的开源印象笔记。", + "start_url": "/", + "display": "standalone", + "background_color": "#f0fdf4", + "theme_color": "#d1fae5", + "lang": "zh-CN", + "icons": [] +} diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx new file mode 100644 index 00000000..37182904 --- /dev/null +++ b/apps/web/src/app/App.tsx @@ -0,0 +1,804 @@ +import type { MemoDetail, MemoSummary, Notebook, TiptapDoc } from "@edgeever/shared"; +import Placeholder from "@tiptap/extension-placeholder"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Archive, + CheckSquare, + ChevronDown, + ChevronRight, + FilePlus2, + Folder, + Inbox, + LayoutList, + Merge, + MoreHorizontal, + PanelLeft, + Plus, + Save, + Search, + Sparkles, + Tags, + Trash2, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { api } from "@/lib/api"; +import { localDb } from "@/lib/local-db"; +import { buildNotebookTree, cn, formatDateTime, parseTagsText, type NotebookNode } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +type Pane = "notebooks" | "memos" | "editor"; + +export const App = () => { + const queryClient = useQueryClient(); + const [activePane, setActivePane] = useState("memos"); + const [selectedNotebookId, setSelectedNotebookId] = useState(null); + const [selectedMemoId, setSelectedMemoId] = useState(null); + const [selectedMemoIds, setSelectedMemoIds] = useState>(new Set()); + const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); + const [search, setSearch] = useState(""); + + const notebooksQuery = useQuery({ + queryKey: ["notebooks"], + queryFn: () => api.listNotebooks(), + }); + + const notebooks = notebooksQuery.data?.notebooks ?? []; + + useEffect(() => { + if (!selectedNotebookId && notebooks.length > 0) { + setSelectedNotebookId(notebooks[0].id); + } + }, [notebooks, selectedNotebookId]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.ctrlKey || event.metaKey || event.key === "Control" || event.key === "Meta") { + setMultiSelectKeyDown(true); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + setMultiSelectKeyDown(event.ctrlKey || event.metaKey); + }; + + const handleBlur = () => setMultiSelectKeyDown(false); + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); + + const memosQuery = useQuery({ + queryKey: ["memos", selectedNotebookId, search], + queryFn: () => api.listMemos({ notebookId: selectedNotebookId, q: search }), + enabled: Boolean(selectedNotebookId), + }); + + const memos = memosQuery.data?.memos ?? []; + + useEffect(() => { + if (memos.length === 0) { + setSelectedMemoId(null); + return; + } + + if (!selectedMemoId || !memos.some((memo) => memo.id === selectedMemoId)) { + setSelectedMemoId(memos[0].id); + } + }, [memos, selectedMemoId]); + + const memoQuery = useQuery({ + queryKey: ["memo", selectedMemoId], + queryFn: () => api.getMemo(selectedMemoId as string), + enabled: Boolean(selectedMemoId), + }); + + const createNotebookMutation = useMutation({ + mutationFn: api.createNotebook, + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); + setSelectedNotebookId(data.notebook.id); + setActivePane("memos"); + }, + }); + + const createMemoMutation = useMutation({ + mutationFn: api.createMemo, + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); + setSelectedMemoId(data.memo.id); + setActivePane("editor"); + }, + }); + + const mergeMutation = useMutation({ + mutationFn: api.mergeMemos, + onSuccess: async (data) => { + setSelectedMemoIds(new Set()); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); + setSelectedMemoId(data.memo.id); + setActivePane("editor"); + }, + }); + + const deleteMemoMutation = useMutation({ + mutationFn: api.deleteMemo, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + }, + }); + + const selectedNotebook = notebooks.find((notebook) => notebook.id === selectedNotebookId) ?? null; + const selectedMemo = memoQuery.data?.memo ?? null; + + const handleCreateNotebook = (parentId?: string | null) => { + const name = window.prompt("新笔记本名称"); + + if (!name?.trim()) { + return; + } + + createNotebookMutation.mutate({ name: name.trim(), parentId: parentId ?? null }); + }; + + const handleCreateMemo = () => { + if (!selectedNotebookId) { + return; + } + + createMemoMutation.mutate({ + notebookId: selectedNotebookId, + title: "Untitled memo", + contentMarkdown: "", + tags: [], + }); + }; + + const handleMerge = () => { + if (!selectedNotebookId || selectedMemoIds.size < 2) { + return; + } + + mergeMutation.mutate({ + memoIds: Array.from(selectedMemoIds), + notebookId: selectedNotebookId, + }); + }; + + return ( +
+
+
+ + +
+ setActivePane("notebooks")} + onSearch={setSearch} + onCreateMemo={handleCreateMemo} + onOpenMemo={(memoId) => { + setSelectedMemoId(memoId); + setActivePane("editor"); + }} + onToggleMemo={(memoId) => { + setSelectedMemoIds((current) => toggleMemoSelection(current, memoId)); + }} + onMerge={handleMerge} + /> +
+ +
+ setActivePane("memos")} + onSaved={async (memo) => { + queryClient.setQueryData(["memo", memo.id], { memo }); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + }} + onDeleted={async (memoId) => { + await deleteMemoMutation.mutateAsync(memoId); + setSelectedMemoId(null); + setActivePane("memos"); + }} + /> +
+
+
+
+ ); +}; + +const toggleMemoSelection = (current: Set, memoId: string) => { + const next = new Set(current); + + if (next.has(memoId)) { + next.delete(memoId); + } else { + next.add(memoId); + } + + return next; +}; + +const NotebookPane = ({ + notebooks, + selectedNotebookId, + isLoading, + onSelect, + onCreateNotebook, + onBackToList, +}: { + notebooks: Notebook[]; + selectedNotebookId: string | null; + isLoading: boolean; + onSelect: (notebookId: string) => void; + onCreateNotebook: (parentId?: string | null) => void; + onBackToList: () => void; +}) => { + const tree = useMemo(() => buildNotebookTree(notebooks), [notebooks]); + + return ( +
+
+
+
笔记本
+
EdgeEver
+
Cloudflare-native notes
+
+
+ + +
+
+ +
+
+ + Notebooks +
+ + {isLoading ? ( +
加载中
+ ) : ( +
+ {tree.map((node) => ( + + ))} +
+ )} +
+ +
+
+ + + +
+
+
+ ); +}; + +const NotebookTreeItem = ({ + node, + depth, + selectedNotebookId, + onSelect, + onCreateNotebook, +}: { + node: NotebookNode; + depth: number; + selectedNotebookId: string | null; + onSelect: (notebookId: string) => void; + onCreateNotebook: (parentId?: string | null) => void; +}) => { + const [open, setOpen] = useState(true); + const hasChildren = node.children.length > 0; + const selected = node.id === selectedNotebookId; + + return ( +
+
+ + + +
+ + {hasChildren && open ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); +}; + +const MemoListPane = ({ + notebook, + memos, + selectedMemoId, + selectedMemoIds, + search, + isLoading, + isCreating, + isMerging, + multiSelectKeyDown, + onBackToNotebooks, + onSearch, + onCreateMemo, + onOpenMemo, + onToggleMemo, + onMerge, +}: { + notebook: Notebook | null; + memos: MemoSummary[]; + selectedMemoId: string | null; + selectedMemoIds: Set; + search: string; + isLoading: boolean; + isCreating: boolean; + isMerging: boolean; + multiSelectKeyDown: boolean; + onBackToNotebooks: () => void; + onSearch: (value: string) => void; + onCreateMemo: () => void; + onOpenMemo: (memoId: string) => void; + onToggleMemo: (memoId: string) => void; + onMerge: () => void; +}) => ( +
+
+
+
+ +
+
{notebook?.name ?? "全部笔记"}
+
{memos.length} memos
+
+
+ +
+ +
+ +
+ {selectedMemoIds.size > 0 ? ( +
+
+ + {selectedMemoIds.size} selected +
+ +
+ ) : null} + + {isLoading ? ( +
加载中
+ ) : memos.length === 0 ? ( +
+ No memos +
+ ) : ( +
+ {memos.map((memo) => ( + onOpenMemo(memo.id)} + onToggle={() => onToggleMemo(memo.id)} + /> + ))} +
+ )} +
+ {selectedMemoIds.size === 0 ? ( + + ) : null} +
+); + +const MemoCard = ({ + memo, + selected, + checked, + multiSelectKeyDown, + onOpen, + onToggle, +}: { + memo: MemoSummary; + selected: boolean; + checked: boolean; + multiSelectKeyDown: boolean; + onOpen: () => void; + onToggle: () => void; +}) => { + const handledModifierPointerRef = useRef(false); + + const shouldToggleSelection = (event: MouseEvent) => + event.ctrlKey || event.metaKey || multiSelectKeyDown; + + const markModifierPointerHandled = () => { + handledModifierPointerRef.current = true; + window.setTimeout(() => { + handledModifierPointerRef.current = false; + }, 450); + }; + + const handleModifierToggle = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + markModifierPointerHandled(); + onToggle(); + }; + + const handleMouseDown = (event: MouseEvent) => { + if (shouldToggleSelection(event)) { + handleModifierToggle(event); + } + }; + + const handleClick = (event: MouseEvent) => { + if (handledModifierPointerRef.current) { + event.preventDefault(); + event.stopPropagation(); + handledModifierPointerRef.current = false; + return; + } + + if (shouldToggleSelection(event)) { + handleModifierToggle(event); + return; + } + + onOpen(); + }; + + const handleContextMenu = (event: MouseEvent) => { + if (!shouldToggleSelection(event) && !handledModifierPointerRef.current) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!handledModifierPointerRef.current) { + markModifierPointerHandled(); + onToggle(); + } + }; + + return ( +
+
+ + +
+
+ ); +}; + +const EditorPane = ({ + memo, + notebooks, + isLoading, + onBackToList, + onSaved, + onDeleted, +}: { + memo: MemoDetail | null; + notebooks: Notebook[]; + isLoading: boolean; + onBackToList: () => void; + onSaved: (memo: MemoDetail) => Promise; + onDeleted: (memoId: string) => Promise; +}) => { + const [title, setTitle] = useState(""); + const [tagsText, setTagsText] = useState(""); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle"); + const editor = useEditor({ + extensions: [ + StarterKit, + Placeholder.configure({ + placeholder: "Start writing...", + }), + ], + content: memo?.contentJson ?? { type: "doc", content: [{ type: "paragraph" }] }, + editorProps: { + attributes: { + class: "prose prose-slate max-w-none", + }, + }, + }); + + useEffect(() => { + if (!memo) { + setTitle(""); + setTagsText(""); + editor?.commands.clearContent(); + return; + } + + setTitle(memo.title ?? ""); + setTagsText(memo.tags.join(", ")); + + if (editor) { + editor.commands.setContent(memo.contentJson); + } + }, [editor, memo]); + + useEffect(() => { + if (!editor || !memo) { + return; + } + + const persistDraft = () => { + void localDb.drafts.put({ + memoId: memo.id, + title, + tagsText, + contentJson: editor.getJSON() as TiptapDoc, + updatedAt: new Date().toISOString(), + }); + }; + + editor.on("update", persistDraft); + return () => { + editor.off("update", persistDraft); + }; + }, [editor, memo, tagsText, title]); + + const saveMutation = useMutation({ + mutationFn: async () => { + if (!memo || !editor) { + throw new Error("No memo selected"); + } + + return api.updateMemo(memo.id, { + expectedRevision: memo.revision, + title, + contentJson: editor.getJSON() as TiptapDoc, + tags: parseTagsText(tagsText), + }); + }, + onMutate: () => setSaveState("saving"), + onSuccess: async (data) => { + await localDb.drafts.delete(data.memo.id); + await onSaved(data.memo); + setSaveState("saved"); + window.setTimeout(() => setSaveState("idle"), 1400); + }, + onError: () => setSaveState("idle"), + }); + + if (isLoading) { + return
加载中
; + } + + if (!memo) { + return ( +
+
+ +
Select or create a memo
+
+
+ ); + } + + return ( +
+
+
+
+ + +
+ +
+ + +
+
+ +
+ setTitle(event.target.value)} + 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" + /> + +
+
+ +
+ +
+
+ ); +}; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 00000000..1407eb2a --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/lib/utils"; +import type { ButtonHTMLAttributes } from "react"; + +type ButtonVariant = "solid" | "soft" | "ghost" | "outline" | "danger"; +type ButtonSize = "sm" | "md" | "icon"; + +type ButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant; + size?: ButtonSize; +}; + +const variantClassName: Record = { + solid: "bg-emerald-100 text-emerald-950 hover:bg-emerald-200 border-emerald-200", + soft: "bg-emerald-50 text-emerald-950 hover:bg-emerald-100 border-emerald-100", + ghost: "bg-transparent text-emerald-800 hover:bg-emerald-50 hover:text-emerald-950 border-transparent", + outline: "bg-white text-emerald-900 hover:bg-emerald-50 border-emerald-200", + danger: "bg-rose-50 text-rose-700 hover:bg-rose-100 border-rose-100", +}; + +const sizeClassName: Record = { + sm: "h-8 gap-1.5 px-2.5 text-xs", + md: "h-9 gap-2 px-3 text-sm", + icon: "h-8 w-8 justify-center p-0", +}; + +export const Button = ({ className, variant = "soft", size = "md", ...props }: ButtonProps) => ( +