diff --git a/apps/api/src/auth-routes.ts b/apps/api/src/auth-routes.ts index 50b092db..5544cfb2 100644 --- a/apps/api/src/auth-routes.ts +++ b/apps/api/src/auth-routes.ts @@ -59,7 +59,12 @@ type AuthRouteDependencies = { authenticateRequest: (context: AppContext, touch: boolean) => Promise; authenticateSession: (context: AppContext, touch: boolean) => Promise; createSession: (context: AppContext, user: UserRow, requestedDeviceId?: string) => Promise; - ensureUserWorkspace: (database: DatabaseAdapter, userId: string, username: string) => Promise; + ensureUserWorkspace: ( + database: DatabaseAdapter, + userId: string, + username: string, + locale?: string | null, + ) => Promise; getBearerToken: (context: AppContext) => string | null; getInstanceAuthMode: (environment: Bindings) => Promise; getLoginAttemptKeys: (context: AppContext, username: string) => Promise; @@ -273,6 +278,7 @@ export const registerAuthRoutes = ( context.env.storage.db, user.id, user.username, + context.req.header("accept-language"), ); const session = await dependencies.createSession(context, user, input.deviceId); dependencies.setSessionCookie(context, session.token, session.maxAge); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2bb8a5e7..689009b8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1313,7 +1313,7 @@ const authenticateSessionToken = async (c: AppContext, token: string, touch: boo const workspace = row.workspace_id && row.role ? { workspaceId: row.workspace_id, role: row.role } - : await ensureUserWorkspace(c.env.storage.db, row.user_id, row.username); + : await ensureUserWorkspace(c.env.storage.db, row.user_id, row.username, c.req.header("accept-language")); return { kind: "user", diff --git a/apps/api/src/template-seed-migration.test.mjs b/apps/api/src/template-seed-migration.test.mjs new file mode 100644 index 00000000..4248ab0d --- /dev/null +++ b/apps/api/src/template-seed-migration.test.mjs @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { readFileSync } from "node:fs"; + +const migration = (name) => readFileSync(`migrations/${name}`, "utf8"); + +describe("editable template migration", () => { + test("replaces untouched legacy seed and preserves a user-edited copy", () => { + const sqlite = new Database(":memory:"); + sqlite.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE workspaces (id TEXT PRIMARY KEY); + INSERT INTO workspaces (id) VALUES ('ws_clean'), ('ws_edited'); + `); + sqlite.exec(migration("0016_templates.sql")); + sqlite.exec(migration("0017_seed_default_templates.sql")); + sqlite.exec(migration("0018_fix_seed_template_markdown.sql")); + sqlite.query(` + UPDATE memo_templates + SET name = '我的项目周报', updated_at = '2099-01-01T00:00:00.000Z' + WHERE workspace_id = 'ws_edited' + `).run(); + + sqlite.exec(migration("0030_unify_editable_note_templates.sql")); + + const clean = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_clean"); + const edited = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_edited"); + expect(clean).toHaveLength(6); + expect(clean.some((row) => row.id === "ws_clean_template_project_weekly")).toBe(false); + expect(edited).toHaveLength(7); + expect(edited.some((row) => row.name === "我的项目周报")).toBe(true); + expect(clean.every((row) => !row.name.includes("内置"))).toBe(true); + }); +}); diff --git a/apps/api/src/user-routes.ts b/apps/api/src/user-routes.ts index 624a35b5..be2b5718 100644 --- a/apps/api/src/user-routes.ts +++ b/apps/api/src/user-routes.ts @@ -99,7 +99,12 @@ export const registerUserRoutes = ( `INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, 'notebook', ?, ?, ?, ?)`, ).bind(notebook.id, workspaceId, notebook.name, notebook.slug, notebook.color, notebook.sortOrder, now, now)), - ...createWorkspaceDefaultSeedStatements(context.env.storage.db, workspaceId, now), + ...createWorkspaceDefaultSeedStatements( + context.env.storage.db, + workspaceId, + now, + context.req.header("accept-language"), + ), auditStatement(context.env.storage.db, "user", context.get("auth").actorId, "user.create", "user", userId, { username: input.username, }), diff --git a/apps/api/src/workspace-provisioning.test.mjs b/apps/api/src/workspace-provisioning.test.mjs index cea92c24..8b7fc3c8 100644 --- a/apps/api/src/workspace-provisioning.test.mjs +++ b/apps/api/src/workspace-provisioning.test.mjs @@ -40,7 +40,7 @@ describe("workspace provisioning", () => { expect(calls[0].sql).toContain("FROM workspace_members WHERE user_id"); }); - test("builds static template and AI prompt statements for new workspaces", () => { + test("builds editable localized template and AI prompt statements for new workspaces", () => { const calls = []; const db = { prepare: (sql) => statement(sql, calls), @@ -50,11 +50,14 @@ describe("workspace provisioning", () => { db, "ws_new", "2026-08-14T00:00:00.000Z", + "en-US", ); - expect(statements.length).toBeGreaterThan(1); + expect(statements.length).toBeGreaterThan(6); expect(calls[0].sql).toContain("INSERT OR IGNORE INTO memo_templates"); - expect(calls[0].values).toContain("ws_new_template_project_weekly"); + expect(calls[0].values).toContain("ws_new_template_quick-note"); + expect(calls[0].values).toContain("Quick Spark"); + expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(6); expect(calls.some((call) => call.sql.includes("INSERT OR IGNORE INTO ai_prompt_templates"))).toBe(true); }); }); diff --git a/apps/api/src/workspace-provisioning.ts b/apps/api/src/workspace-provisioning.ts index 9d96f0e2..7c6e1801 100644 --- a/apps/api/src/workspace-provisioning.ts +++ b/apps/api/src/workspace-provisioning.ts @@ -2,6 +2,11 @@ import { DEFAULT_AI_PROMPT_SEEDS, defaultAiPromptId, } from "@edgeever/shared/ai-prompt-seeds"; +import { + DEFAULT_MEMO_TEMPLATE_SEEDS, + defaultMemoTemplateId, + localizeMemoTemplateSeed, +} from "@edgeever/shared/memo-template-seeds"; import { createId, isoNow } from "./entity-utils"; import type { DatabaseAdapter, @@ -31,55 +36,36 @@ export const createDefaultNotebookRows = (workspaceId: string): DefaultNotebookR { id: `${workspaceId}_personal`, name: "生活个人", slug: "personal-life", color: "#ea580c", sortOrder: 50 }, ]; -const PROJECT_WEEKLY_TEMPLATE_MARKDOWN = "## 本周进展\n\n- \n\n## 关键成果\n\n- \n\n## 风险与阻塞\n\n- \n\n## 下周计划\n\n- [ ] \n\n## 需要协助\n\n- "; - -// This is the build-time result of parsing PROJECT_WEEKLY_TEMPLATE_MARKDOWN. -// Keeping it static avoids loading and executing the TipTap Markdown parser -// merely to provision a workspace. -const PROJECT_WEEKLY_TEMPLATE_CONTENT_JSON = JSON.stringify({ - type: "doc", - content: [ - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "本周进展" }] }, - { type: "paragraph", content: [{ type: "text", text: "- " }] }, - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "关键成果" }] }, - { type: "paragraph", content: [{ type: "text", text: "- " }] }, - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "风险与阻塞" }] }, - { type: "paragraph", content: [{ type: "text", text: "- " }] }, - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "下周计划" }] }, - { - type: "taskList", - content: [{ - type: "taskItem", - attrs: { checked: false }, - content: [{ type: "paragraph", content: [] }], - }], - }, - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "需要协助" }] }, - { type: "paragraph", content: [{ type: "text", text: "- " }] }, - ], -}); +// Template use and editing derive the TipTap document from content_markdown. +// Keeping this initial value static avoids loading the Markdown parser merely +// to provision a workspace. +const EMPTY_TEMPLATE_CONTENT_JSON = JSON.stringify({ type: "doc", content: [] }); export const createWorkspaceDefaultSeedStatements = ( db: DatabaseAdapter, workspaceId: string, now = isoNow(), + locale?: string | null, ): PreparedStatementAdapter[] => [ - db.prepare( - `INSERT OR IGNORE INTO memo_templates ( - id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).bind( - `${workspaceId}_template_project_weekly`, - workspaceId, - "项目周报模板", - "每周同步项目进展、风险与下一步计划", - "项目周报|第 {{周次}} 周", - PROJECT_WEEKLY_TEMPLATE_CONTENT_JSON, - PROJECT_WEEKLY_TEMPLATE_MARKDOWN, - JSON.stringify(["项目管理", "周报"]), - now, - now, - ), + ...DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => { + const localized = localizeMemoTemplateSeed(seed, locale); + return db.prepare( + `INSERT OR IGNORE INTO memo_templates ( + id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + defaultMemoTemplateId(workspaceId, seed.key), + workspaceId, + localized.title, + localized.description, + localized.title, + EMPTY_TEMPLATE_CONTENT_JSON, + localized.contentMarkdown, + JSON.stringify(["template", seed.tag]), + now, + now, + ); + }), ...DEFAULT_AI_PROMPT_SEEDS.map((seed) => db.prepare( `INSERT OR IGNORE INTO ai_prompt_templates ( id, workspace_id, seed_key, action, parameter_kind, result_mode, @@ -106,6 +92,7 @@ export const ensureUserWorkspace = async ( db: DatabaseAdapter, userId: string, username: string, + locale?: string | null, ): Promise => { const existing = await db.prepare( `SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`, @@ -125,7 +112,7 @@ export const ensureUserWorkspace = async ( `SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`, ).bind(userId).first<{ workspace_id: string; role: "owner" | "member" }>(); if (claimed) { - await db.batch(createWorkspaceDefaultSeedStatements(db, claimed.workspace_id)); + await db.batch(createWorkspaceDefaultSeedStatements(db, claimed.workspace_id, isoNow(), locale)); return { workspaceId: claimed.workspace_id, role: claimed.role }; } } @@ -153,7 +140,7 @@ export const ensureUserWorkspace = async ( now, now, )), - ...createWorkspaceDefaultSeedStatements(db, workspaceId, now), + ...createWorkspaceDefaultSeedStatements(db, workspaceId, now, locale), ]); return { workspaceId, role: "member" }; }; diff --git a/apps/ios/EdgeEver/Data/Models/MemoTemplates.swift b/apps/ios/EdgeEver/Data/Models/MemoTemplates.swift index c0595e26..8ce261de 100644 --- a/apps/ios/EdgeEver/Data/Models/MemoTemplates.swift +++ b/apps/ios/EdgeEver/Data/Models/MemoTemplates.swift @@ -1,7 +1,5 @@ import Foundation -// MARK: - Server template - struct MemoTemplate: Codable, Equatable, Sendable, Identifiable { var id: String var name: String @@ -17,8 +15,6 @@ struct TemplatesResponse: Codable, Sendable { var templates: [MemoTemplate] } -// MARK: - Create seed (Android MobileCreateMemoSeed) - struct CreateMemoSeed: Equatable, Sendable { var title: String var contentMarkdown: String @@ -31,13 +27,6 @@ struct CreateMemoSeed: Equatable, Sendable { } } -// MARK: - Selectable row (built-in + saved) - -enum MemoTemplateSource: String, Equatable, Sendable { - case builtin - case saved -} - struct SelectableMemoTemplate: Identifiable, Equatable, Sendable { var id: String var name: String @@ -45,7 +34,15 @@ struct SelectableMemoTemplate: Identifiable, Equatable, Sendable { var title: String var contentMarkdown: String var tags: [String] - var source: MemoTemplateSource + + init(_ template: MemoTemplate) { + id = template.id + name = template.name + description = template.description ?? "" + title = template.title ?? template.name + contentMarkdown = template.contentMarkdown + tags = template.tags + } var createSeed: CreateMemoSeed { CreateMemoSeed( @@ -55,107 +52,3 @@ struct SelectableMemoTemplate: Identifiable, Equatable, Sendable { ) } } - -// MARK: - Built-in catalog (parity with apps/mobile + web i18n) - -enum BuiltInMemoTemplates { - private struct Item { - var id: String - var tag: String - var titleZH: String - var titleEN: String - var descriptionZH: String - var descriptionEN: String - var contentMarkdownZH: String - var contentMarkdownEN: String - } - - private static let items: [Item] = [ - Item( - id: "quick-note", - tag: "quick-note", - titleZH: "灵感速记", - titleEN: "Quick Spark", - descriptionZH: "快速捕捉闪念、临时灵感、资料链接与即刻行动。", - descriptionEN: "Capture fleeting thoughts, ideas, links, and immediate action items.", - contentMarkdownZH: "## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ", - contentMarkdownEN: "## 💡 Fleeting Thoughts\n\n- \n\n## 📌 Context & Notes\n\n\n\n## 🚀 Next Actions\n\n- [ ] " - ), - Item( - id: "meeting", - tag: "meeting", - titleZH: "会议纪要", - titleEN: "Meeting Minutes", - descriptionZH: "结构化记录议题背景、核心结论与带负责人的待办事项。", - descriptionEN: "Structured log for agenda, key decisions, and action items with owners.", - contentMarkdownZH: "# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n", - contentMarkdownEN: "# 📝 Meeting Minutes\n\n- **Time**:\n- **Host/Recorder**:\n- **Attendees**:\n\n---\n\n## 🎯 Goal\n\n- \n\n## 💬 Discussion & Decisions\n\n1. **[Topic 1]**\n - Points:\n - ✅ **Decision**:\n\n2. **[Topic 2]**\n - Points:\n - ✅ **Decision**:\n\n## 📋 Action Items\n\n- [ ] **[Owner]** Task description (Due: MM-DD)\n- [ ] **[Owner]** Task description (Due: MM-DD)\n" - ), - Item( - id: "weekly-review", - tag: "weekly-review", - titleZH: "周报与进展复盘", - titleEN: "Weekly Review & Status", - descriptionZH: "梳理本周核心产出、风险卡点与下周关键优先级。", - descriptionEN: "Summarize weekly highlights, blockers, and next week's key priorities.", - contentMarkdownZH: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n", - contentMarkdownEN: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n" - ), - Item( - id: "reading", - tag: "reading", - titleZH: "深度阅读卡片", - titleEN: "Reading Note Card", - descriptionZH: "提炼核心观点、精妙摘录、个人理解与关联知识卡片。", - descriptionEN: "Extract key takeaways, quotes, reflections, and connected concepts.", - contentMarkdownZH: "# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n", - contentMarkdownEN: "# 📖 Reading Note Card\n\n- **Book/Article**:\n- **Author/Source**:\n- **Rating**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Key Takeaway\n\n> \n\n## ✍️ Highlights & Quotes\n\n> [Quote content]\n> —— *Original Source*\n\n## 🧠 Personal Reflections\n\n- \n\n## 🔗 Action & Practice\n\n- [ ] **Action Plan**:\n" - ), - Item( - id: "okr", - tag: "okr", - titleZH: "目标与任务拆解", - titleEN: "Goal & Task Breakdown", - descriptionZH: "明确 OKR 目标、关键结果、里程碑与具体执行清单。", - descriptionEN: "Define OKRs, Key Results, milestones, and task checklists.", - contentMarkdownZH: "# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n", - contentMarkdownEN: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n" - ), - Item( - id: "post-mortem", - tag: "post-mortem", - titleZH: "问题排查与复盘", - titleEN: "Problem & Post-mortem", - descriptionZH: "记录故障现象、根因分析 (5 Whys) 与防范机制。", - descriptionEN: "Document incident symptoms, 5-Whys root cause, and preventive measures.", - contentMarkdownZH: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n", - contentMarkdownEN: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n" - ), - ] - - static func all(isEnglish: Bool) -> [SelectableMemoTemplate] { - items.map { item in - SelectableMemoTemplate( - id: item.id, - name: isEnglish ? item.titleEN : item.titleZH, - description: isEnglish ? item.descriptionEN : item.descriptionZH, - title: isEnglish ? item.titleEN : item.titleZH, - contentMarkdown: isEnglish ? item.contentMarkdownEN : item.contentMarkdownZH, - tags: ["template", item.tag], - source: .builtin - ) - } - } - - static func fromSaved(_ template: MemoTemplate) -> SelectableMemoTemplate { - SelectableMemoTemplate( - id: template.id, - name: template.name, - description: template.description ?? "", - title: template.title ?? template.name, - contentMarkdown: template.contentMarkdown, - tags: template.tags, - source: .saved - ) - } -} diff --git a/apps/ios/EdgeEver/Features/Workspace/TemplatePickerSheet.swift b/apps/ios/EdgeEver/Features/Workspace/TemplatePickerSheet.swift index 0cc9e51b..d9a173d8 100644 --- a/apps/ios/EdgeEver/Features/Workspace/TemplatePickerSheet.swift +++ b/apps/ios/EdgeEver/Features/Workspace/TemplatePickerSheet.swift @@ -111,20 +111,16 @@ struct CreateChoiceSheet: View { } } -/// Android `MobileTemplatePickerModal` — saved + built-in templates. +/// Android `MobileTemplatePickerModal` — persisted, editable templates. struct TemplatePickerSheet: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss var onSelect: (CreateMemoSeed) -> Void - @State private var saved: [SelectableMemoTemplate] = [] - @State private var isLoadingSaved = false - @State private var savedLoadFailed = false - - private var builtIn: [SelectableMemoTemplate] { - BuiltInMemoTemplates.all(isEnglish: env.preferences.isEnglish) - } + @State private var templates: [SelectableMemoTemplate] = [] + @State private var isLoading = false + @State private var loadFailed = false var body: some View { VStack(spacing: 0) { @@ -140,8 +136,8 @@ struct TemplatePickerSheet: View { .font(.system(size: 15, weight: .heavy)) .foregroundStyle(AppTheme.title) Text(env.preferences.t( - "选择预设结构快速开始,也可使用网页端保存的自定义模板。", - en: "Start from a preset structure, or use custom templates saved on the web." + "选择一个模板快速开始。所有模板都可以在网页端修改或删除。", + en: "Choose a template to get started. Every template can be edited or deleted on the web." )) .font(.system(size: 12)) .foregroundStyle(AppTheme.secondary) @@ -167,9 +163,9 @@ struct TemplatePickerSheet: View { ScrollView { VStack(alignment: .leading, spacing: 0) { - sectionTitle(env.preferences.t("我的自定义模板", en: "My custom templates")) + sectionTitle(env.preferences.t("模板", en: "Templates")) - if isLoadingSaved { + if isLoading { HStack(spacing: 8) { ProgressView().controlSize(.small) Text(env.preferences.t("正在加载模板", en: "Loading templates")) @@ -178,26 +174,19 @@ struct TemplatePickerSheet: View { } .padding(.horizontal, 8) .padding(.vertical, 10) - } else if savedLoadFailed { + } else if loadFailed { hint(env.preferences.t( - "自定义模板暂时无法加载,仍可使用下方内置模板。", - en: "Custom templates could not load. Built-in templates are still available." + "模板暂时无法加载,请稍后重试。", + en: "Templates could not load. Please try again later." )) - } else if saved.isEmpty { + } else if templates.isEmpty { hint(env.preferences.t( - "暂无自定义模板。可在网页端将常用笔记另存为模板。", - en: "No custom templates yet. Save notes as templates on the web." + "暂无模板。可在网页端新建模板,或将常用笔记另存为模板。", + en: "No templates yet. Create one or save a note as a template on the web." )) } - ForEach(saved) { template in - templateRow(template) - } - - divider - - sectionTitle(env.preferences.t("内置推荐模板", en: "Recommended templates")) - ForEach(builtIn) { template in + ForEach(templates) { template in templateRow(template) } } @@ -210,19 +199,19 @@ struct TemplatePickerSheet: View { .presentationDetents([.medium, .large]) .presentationDragIndicator(.hidden) .accessibilityIdentifier("templatePickerSheet") - .task { await loadSaved() } + .task { await loadTemplates() } } - private func loadSaved() async { - isLoadingSaved = true - savedLoadFailed = false - defer { isLoadingSaved = false } + private func loadTemplates() async { + isLoading = true + loadFailed = false + defer { isLoading = false } do { let list = try await env.session.client.listTemplates() - saved = list.map(BuiltInMemoTemplates.fromSaved) + templates = list.map(SelectableMemoTemplate.init) } catch { - savedLoadFailed = true - saved = [] + loadFailed = true + templates = [] } } @@ -244,13 +233,6 @@ struct TemplatePickerSheet: View { .padding(.vertical, 8) } - private var divider: some View { - Rectangle() - .fill(AppTheme.searchFill) - .frame(height: 1) - .padding(.vertical, 8) - } - private func templateRow(_ template: SelectableMemoTemplate) -> some View { Button { onSelect(template.createSeed) @@ -272,7 +254,6 @@ struct TemplatePickerSheet: View { .foregroundStyle(AppTheme.title) .lineLimit(1) Spacer(minLength: 0) - badge(for: template.source) } if !template.description.isEmpty { Text(template.description) @@ -291,19 +272,4 @@ struct TemplatePickerSheet: View { .accessibilityIdentifier("templateRow-\(template.id)") } - private func badge(for source: MemoTemplateSource) -> some View { - let isCustom = source == .saved - return Text(isCustom - ? env.preferences.t("自定义", en: "Custom") - : env.preferences.t("内置", en: "Built-in")) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(isCustom ? AppTheme.accentStrong : AppTheme.secondary) - .padding(.horizontal, 7) - .padding(.vertical, 2) - .background(isCustom ? AppTheme.accentSoft : AppTheme.searchFill) - .overlay( - Capsule().stroke(isCustom ? AppTheme.accentBorder : AppTheme.border, lineWidth: 1) - ) - .clipShape(Capsule()) - } } diff --git a/apps/ios/Tests/EdgeEverTests/MemoTemplatesTests.swift b/apps/ios/Tests/EdgeEverTests/MemoTemplatesTests.swift index 91d6ad7d..2f9feaeb 100644 --- a/apps/ios/Tests/EdgeEverTests/MemoTemplatesTests.swift +++ b/apps/ios/Tests/EdgeEverTests/MemoTemplatesTests.swift @@ -2,29 +2,17 @@ import XCTest @testable import EdgeEver final class MemoTemplatesTests: XCTestCase { - func testBuiltInCatalogHasSixItemsBothLocales() { - let zh = BuiltInMemoTemplates.all(isEnglish: false) - let en = BuiltInMemoTemplates.all(isEnglish: true) - XCTAssertEqual(zh.count, 6) - XCTAssertEqual(en.count, 6) - XCTAssertEqual(zh[0].name, "灵感速记") - XCTAssertEqual(en[0].name, "Quick Spark") - XCTAssertTrue(zh[1].contentMarkdown.contains("会议纪要")) - XCTAssertTrue(en[1].contentMarkdown.contains("Meeting Minutes")) - XCTAssertEqual(zh[0].source, .builtin) - XCTAssertEqual(zh[0].tags, ["template", "quick-note"]) - } - func testCreateSeedFromSelectableTemplate() { - let template = SelectableMemoTemplate( + let template = SelectableMemoTemplate(MemoTemplate( id: "tpl", name: "我的周报", description: "desc", title: "【周报】", contentMarkdown: "## 本周", tags: ["work", "weekly"], - source: .saved - ) + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + )) XCTAssertEqual(template.createSeed.title, "【周报】") XCTAssertEqual(template.createSeed.contentMarkdown, "## 本周") XCTAssertEqual(template.createSeed.tagsText, "work, weekly") @@ -44,8 +32,7 @@ final class MemoTemplatesTests: XCTestCase { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" ) - let row = BuiltInMemoTemplates.fromSaved(saved) - XCTAssertEqual(row.source, .saved) + let row = SelectableMemoTemplate(saved) XCTAssertEqual(row.title, "自定义") XCTAssertEqual(row.contentMarkdown, "# Hi") } diff --git a/apps/mobile/src/components/MobileTemplatePicker.tsx b/apps/mobile/src/components/MobileTemplatePicker.tsx index 3317e027..14041cf8 100644 --- a/apps/mobile/src/components/MobileTemplatePicker.tsx +++ b/apps/mobile/src/components/MobileTemplatePicker.tsx @@ -1,16 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import type { createEdgeEverClient } from "@edgeever/client"; -import { useMemo } from "react"; import { Modal, Pressable, ScrollView, View } from "react-native"; import { ActivityIndicator, FileText, LayoutTemplate, X } from "./icons"; import { Text } from "./LocalizedText"; import { - getMobileBuiltInTemplates, mobileTemplateToCreateSeed, toMobileSelectableTemplate, type MobileCreateMemoSeed, type MobileSelectableTemplate, - type MobileSupportedLocale, } from "../lib/mobile-templates"; import { useMobileLocale } from "../lib/mobile-locale"; import { styles } from "../screens/workspace-styles"; @@ -38,11 +35,7 @@ export const MobileTemplatePickerModal = ({ presentation?: "modal" | "overlay"; visible: boolean; }) => { - const { resolvedLocale, translate } = useMobileLocale(); - const builtInTemplates = useMemo( - () => getMobileBuiltInTemplates(resolvedLocale as MobileSupportedLocale).map((template) => toMobileSelectableTemplate(template, "builtin")), - [resolvedLocale] - ); + const { translate } = useMobileLocale(); const savedTemplatesQuery = useQuery({ queryKey: ["mobile", "templates"], @@ -55,7 +48,7 @@ export const MobileTemplatePickerModal = ({ throw new Error("Client is missing listTemplates; reload the app to pick up the latest bundle."); } const response = await client.listTemplates(); - return response.templates.map((template) => toMobileSelectableTemplate(template, "saved")); + return response.templates.map(toMobileSelectableTemplate); }, staleTime: 15_000, retry: 1, @@ -93,7 +86,7 @@ export const MobileTemplatePickerModal = ({ {translate("从模板新建")} - {translate("选择预设结构快速开始,也可使用网页端保存的自定义模板。")} + {translate("选择一个模板快速开始。所有模板都可以在网页端修改或删除。")} @@ -102,7 +95,7 @@ export const MobileTemplatePickerModal = ({ - {translate("我的自定义模板")} + {translate("模板")} {isLoadingSaved ? ( @@ -112,7 +105,7 @@ export const MobileTemplatePickerModal = ({ {!isLoadingSaved && savedTemplatesQuery.isError ? ( - {translate("自定义模板暂时无法加载,仍可使用下方内置模板。")} + {translate("模板暂时无法加载,请稍后重试。")} {savedLoadErrorMessage ? ( @@ -130,17 +123,11 @@ export const MobileTemplatePickerModal = ({ ) : null} {!isLoadingSaved && !savedTemplatesQuery.isError && savedTemplates.length === 0 ? ( - {translate("暂无自定义模板。可在网页端将常用笔记另存为模板。")} + {translate("暂无模板。可在网页端新建模板,或将常用笔记另存为模板。")} ) : null} {savedTemplates.map((template) => ( - handleSelect(template)} template={template} /> - ))} - - - {translate("内置推荐模板")} - {builtInTemplates.map((template) => ( - handleSelect(template)} template={template} /> + handleSelect(template)} template={template} /> ))} @@ -237,7 +224,6 @@ const TemplateRow = ({ onPress: () => void; template: MobileSelectableTemplate; }) => { - const { translate } = useMobileLocale(); return ( @@ -246,11 +232,6 @@ const TemplateRow = ({ {template.name} - - - {template.source === "saved" ? translate("自定义") : translate("内置")} - - {template.description ? ( {template.description} diff --git a/apps/mobile/src/lib/mobile-locale.tsx b/apps/mobile/src/lib/mobile-locale.tsx index de5c2eb5..69f050c1 100644 --- a/apps/mobile/src/lib/mobile-locale.tsx +++ b/apps/mobile/src/lib/mobile-locale.tsx @@ -141,19 +141,16 @@ const mobileOnlyTranslations = new Map([ ["筛选:{{filter}} · {{count}} 条", "Filter: {{filter}} · {{count}} notes"], ["已加载 {{loaded}} / {{total}} 条笔记", "Loaded {{loaded}} of {{total}} notes"], ["从模板新建", "New from template"], - ["选择预设结构快速开始,也可使用网页端保存的自定义模板。", "Start from a preset structure, or use custom templates saved on the web."], - ["我的自定义模板", "My custom templates"], - ["内置推荐模板", "Recommended templates"], + ["模板", "Templates"], + ["选择一个模板快速开始。所有模板都可以在网页端修改或删除。", "Choose a template to get started. Every template can be edited or deleted on the web."], + ["模板暂时无法加载,请稍后重试。", "Templates could not load. Please try again later."], + ["暂无模板。可在网页端新建模板,或将常用笔记另存为模板。", "No templates yet. Create one or save a note as a template on the web."], ["正在加载模板", "Loading templates"], - ["自定义模板暂时无法加载,仍可使用下方内置模板。", "Custom templates could not load. Built-in templates are still available."], - ["暂无自定义模板。可在网页端将常用笔记另存为模板。", "No custom templates yet. Save notes as templates on the web."], ["新建笔记", "New note"], ["选择创建方式", "Choose how to create"], ["空白笔记", "Blank note"], ["从空白页开始记录", "Start with an empty page"], ["使用会议纪要、周报等预设结构", "Use meeting notes, weekly reviews, and more"], - ["自定义", "Custom"], - ["内置", "Built-in"], ["模板", "Template"], ["应用模板?", "Apply template?"], ["当前内容将被模板内容替换。", "The current content will be replaced by the template."], diff --git a/apps/mobile/src/lib/mobile-templates.test.ts b/apps/mobile/src/lib/mobile-templates.test.ts index 5fbf13e5..7d66f3ca 100644 --- a/apps/mobile/src/lib/mobile-templates.test.ts +++ b/apps/mobile/src/lib/mobile-templates.test.ts @@ -1,29 +1,12 @@ import { describe, expect, test } from "bun:test"; import { createMemoSeedHasContent, - getMobileBuiltInTemplates, mobileTemplateToCreateSeed, toMobileSelectableTemplate, } from "./mobile-templates"; describe("mobile-templates", () => { - test("returns built-in templates for both locales", () => { - const zh = getMobileBuiltInTemplates("zh-CN"); - const en = getMobileBuiltInTemplates("en-US"); - expect(zh.length).toBe(6); - expect(en.length).toBe(6); - expect(zh[0]?.title).toBe("灵感速记"); - expect(en[0]?.title).toBe("Quick Spark"); - expect(zh[1]?.contentMarkdown).toContain("会议纪要"); - expect(en[1]?.contentMarkdown).toContain("Meeting Minutes"); - }); - - test("maps built-in and saved templates to selectable rows", () => { - const builtIn = getMobileBuiltInTemplates("zh-CN")[0]!; - const selectableBuiltIn = toMobileSelectableTemplate(builtIn, "builtin"); - expect(selectableBuiltIn.source).toBe("builtin"); - expect(selectableBuiltIn.name).toBe(builtIn.title); - + test("maps a persisted template to a selectable row", () => { const selectableSaved = toMobileSelectableTemplate( { id: "tpl_1", @@ -36,9 +19,7 @@ describe("mobile-templates", () => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, - "saved" ); - expect(selectableSaved.source).toBe("saved"); expect(selectableSaved.title).toBe("【周报】"); expect(mobileTemplateToCreateSeed(selectableSaved)).toEqual({ title: "【周报】", diff --git a/apps/mobile/src/lib/mobile-templates.ts b/apps/mobile/src/lib/mobile-templates.ts index 9e921896..deab7df4 100644 --- a/apps/mobile/src/lib/mobile-templates.ts +++ b/apps/mobile/src/lib/mobile-templates.ts @@ -1,22 +1,11 @@ -import { enUS, zhCN } from "@edgeever/shared/i18n"; import type { MemoTemplate } from "@edgeever/shared"; -export type MobileSupportedLocale = "zh-CN" | "en-US"; - export type MobileCreateMemoSeed = { title: string; contentMarkdown: string; tagsText: string; }; -export type MobileBuiltInTemplate = { - id: string; - title: string; - description: string; - contentMarkdown: string; - tags: string[]; -}; - export type MobileSelectableTemplate = { id: string; name: string; @@ -24,65 +13,18 @@ export type MobileSelectableTemplate = { title: string; contentMarkdown: string; tags: string[]; - source: "builtin" | "saved"; -}; - -const BUILTIN_TEMPLATE_DEFS = [ - { id: "quick-note", key: "quickNote", tag: "quick-note" }, - { id: "meeting", key: "meeting", tag: "meeting" }, - { id: "weekly-review", key: "weeklyReview", tag: "weekly-review" }, - { id: "reading", key: "reading", tag: "reading" }, - { id: "okr", key: "okr", tag: "okr" }, - { id: "post-mortem", key: "postMortem", tag: "post-mortem" }, -] as const; - -type BuiltInItemKey = (typeof BUILTIN_TEMPLATE_DEFS)[number]["key"]; - -const getTemplateCatalog = (locale: MobileSupportedLocale) => - (locale === "en-US" ? enUS.templates.items : zhCN.templates.items) as Record< - BuiltInItemKey, - { title: string; description: string; contentMarkdown: string } - >; - -export const getMobileBuiltInTemplates = (locale: MobileSupportedLocale): MobileBuiltInTemplate[] => { - const catalog = getTemplateCatalog(locale); - return BUILTIN_TEMPLATE_DEFS.map((item) => { - const content = catalog[item.key]; - return { - id: item.id, - title: content.title, - description: content.description, - contentMarkdown: content.contentMarkdown, - tags: ["template", item.tag], - }; - }); }; export const toMobileSelectableTemplate = ( - template: MobileBuiltInTemplate | MemoTemplate, - source: "builtin" | "saved" + template: MemoTemplate, ): MobileSelectableTemplate => { - if (source === "builtin") { - const builtIn = template as MobileBuiltInTemplate; - return { - id: builtIn.id, - name: builtIn.title, - description: builtIn.description, - title: builtIn.title, - contentMarkdown: builtIn.contentMarkdown, - tags: builtIn.tags, - source, - }; - } - const saved = template as MemoTemplate; return { - id: saved.id, - name: saved.name, - description: saved.description ?? "", - title: saved.title ?? saved.name, - contentMarkdown: saved.contentMarkdown, - tags: saved.tags, - source, + id: template.id, + name: template.name, + description: template.description ?? "", + title: template.title ?? template.name, + contentMarkdown: template.contentMarkdown, + tags: template.tags, }; }; diff --git a/apps/mobile/src/screens/workspace-styles.ts b/apps/mobile/src/screens/workspace-styles.ts index 505f3fc0..40a6d895 100644 --- a/apps/mobile/src/screens/workspace-styles.ts +++ b/apps/mobile/src/screens/workspace-styles.ts @@ -1002,30 +1002,6 @@ const baseWorkspaceStyles = StyleSheet.create({ fontSize: 12, lineHeight: 17, }, - templateBadge: { - borderRadius: 999, - borderWidth: 1, - paddingHorizontal: 7, - paddingVertical: 2, - }, - templateBadgeBuiltIn: { - backgroundColor: "#f8fafc", - borderColor: "#e2e8f0", - }, - templateBadgeCustom: { - backgroundColor: "#ecfdf5", - borderColor: "#a7f3d0", - }, - templateBadgeText: { - fontSize: 10, - fontWeight: "700", - }, - templateBadgeTextBuiltIn: { - color: "#64748b", - }, - templateBadgeTextCustom: { - color: "#047857", - }, templateChoiceRow: { alignItems: "center", borderRadius: 12, diff --git a/apps/web/src/components/TemplatesPane.tsx b/apps/web/src/components/TemplatesPane.tsx index 8793d33f..2745eab7 100644 --- a/apps/web/src/components/TemplatesPane.tsx +++ b/apps/web/src/components/TemplatesPane.tsx @@ -1,20 +1,14 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, type MouseEvent, type ReactNode } from "react"; import { - BookOpen, - Calendar, Check, - CheckSquare, ChevronLeft, Eye, - FileText, LayoutList, Pencil, Plus, Sparkles, - Sun, Trash2, X, - Zap, Tag, ArrowRight, FilePlus @@ -24,32 +18,43 @@ import { marked } from "marked"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { getMemoTemplates, type MemoTemplate } from "@/lib/app-helpers"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui"; import type { MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; -const renderTemplateIcon = (iconName?: string) => { - switch (iconName) { - case "zap": - return ; - case "calendar": - return ; - case "checklist": - return ; - case "book": - return ; - case "sun": - return ; - default: - return ; - } -}; +const TemplateIconAction = ({ + children, + className, + disabled, + label, + onClick, +}: { + children: ReactNode; + className: string; + disabled?: boolean; + label: string; + onClick: (event: MouseEvent) => void; +}) => ( + + + + + {label} + +); export const TemplatesPane = ({ canCreateMemo, isCreating, onClose, - onCreateMemo, onCreateSavedTemplate, savedTemplates, onUseSavedTemplate, @@ -59,7 +64,6 @@ export const TemplatesPane = ({ canCreateMemo: boolean; isCreating: boolean; onClose: () => void; - onCreateMemo: (template: MemoTemplate) => void; onCreateSavedTemplate: (payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => Promise; savedTemplates: SavedMemoTemplate[]; onUseSavedTemplate: (template: SavedMemoTemplate) => void; @@ -67,7 +71,6 @@ export const TemplatesPane = ({ onUpdateSavedTemplate: (templateId: string, payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => Promise; }) => { const { t } = useTranslation(); - const memoTemplates = useMemo(() => getMemoTemplates(t), [t]); const [editingTemplate, setEditingTemplate] = useState(null); const [creatingTemplate, setCreatingTemplate] = useState(false); const [draft, setDraft] = useState({ name: "", description: "", title: "", contentMarkdown: "", tags: "" }); @@ -79,7 +82,6 @@ export const TemplatesPane = ({ noteTitle?: string | null; tags: string[]; contentMarkdown: string; - badge: string; onUse: () => void; } | null>(null); @@ -140,13 +142,19 @@ export const TemplatesPane = ({ ); return ( -
+ +
{/* Header */}
- + + + + + {t("common.back")} +

@@ -161,7 +169,7 @@ export const TemplatesPane = ({
- {/* Section 1: My Custom Templates */} + {/* Every template is workspace-owned and fully editable. */}
@@ -300,7 +308,7 @@ export const TemplatesPane = ({
)} - {/* Custom Templates Grid */} + {/* Templates Grid */} {savedTemplates.length > 0 ? (
{savedTemplates.map((template) => ( @@ -314,7 +322,6 @@ export const TemplatesPane = ({ noteTitle: template.title, tags: template.tags, contentMarkdown: template.contentMarkdown, - badge: t("templates.badgeCustom"), onUse: () => onUseSavedTemplate(template), }) } @@ -327,9 +334,6 @@ export const TemplatesPane = ({

{template.name}

- - {t("templates.badgeCustom")} -

@@ -350,34 +354,31 @@ export const TemplatesPane = ({

- - - +
- - -
-
- ); - })} -

- - {!canCreateMemo && (
{t("templates.unavailable")} @@ -528,12 +439,7 @@ export const TemplatesPane = ({ { if (!open) setPreviewTemplate(null); }}> -
- - {previewTemplate.badge} - - {previewTemplate.title} -
+ {previewTemplate.title} {previewTemplate.description && ( {previewTemplate.description} @@ -613,6 +519,7 @@ export const TemplatesPane = ({
)} -
+
+ ); }; diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 092da106..45441c26 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -50,7 +50,6 @@ import type { NotebookNode, NotebookDropPosition, NotebookMoveOption, - MemoTemplate, MemoFilterMode, MemoSortMode, MemoDocumentAction, @@ -1846,7 +1845,7 @@ export const WorkspaceApp = ({ setNotebookDeleteConfirmation(notebook); }; - const handleCreateMemo = (template?: MemoTemplate) => { + const handleCreateMemo = () => { const targetNotebookId = createMemoNotebookId; if (!targetNotebookId || memoView === "trash") { @@ -1858,9 +1857,9 @@ export const WorkspaceApp = ({ creatingMemoSelectionRef.current = true; createMemoMutation.mutate({ notebookId: targetNotebookId, - title: template?.title ?? "", - contentMarkdown: template?.contentMarkdown ?? "", - tags: template?.tags ?? [], + title: "", + contentMarkdown: "", + tags: [], }); }; @@ -2898,7 +2897,6 @@ export const WorkspaceApp = ({ canCreateMemo={canCreateMemo} isCreating={createMemoMutation.isPending || createTemplateMutation.isPending} onClose={handleCloseTemplates} - onCreateMemo={handleCreateMemo} onCreateSavedTemplate={async (payload) => { await createTemplateMutation.mutateAsync(payload); }} diff --git a/apps/web/src/components/dialogs/TemplatesDialog.tsx b/apps/web/src/components/dialogs/TemplatesDialog.tsx index c072f572..b752fcbc 100644 --- a/apps/web/src/components/dialogs/TemplatesDialog.tsx +++ b/apps/web/src/components/dialogs/TemplatesDialog.tsx @@ -1,17 +1,15 @@ -import { useMemo, useState } from "react"; -import { Check, File as FileIcon, LayoutList, Pencil, X } from "lucide-react"; +import { useState } from "react"; +import { Check, LayoutList, Pencil, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { getMemoTemplates, type MemoTemplate } from "@/lib/app-helpers"; -import type { MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; +import type { MemoTemplate } from "@edgeever/shared"; export const TemplatesDialog = ({ canCreateMemo, isCreating, onClose, - onCreateMemo, savedTemplates, onUseSavedTemplate, onDeleteSavedTemplate, @@ -20,18 +18,16 @@ export const TemplatesDialog = ({ canCreateMemo: boolean; isCreating: boolean; onClose: () => void; - onCreateMemo: (template: MemoTemplate) => void; - savedTemplates: SavedMemoTemplate[]; - onUseSavedTemplate: (template: SavedMemoTemplate) => void; - onDeleteSavedTemplate: (template: SavedMemoTemplate) => void; + savedTemplates: MemoTemplate[]; + onUseSavedTemplate: (template: MemoTemplate) => void; + onDeleteSavedTemplate: (template: MemoTemplate) => void; onUpdateSavedTemplate: (templateId: string, payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => Promise; }) => { const { t } = useTranslation(); - const memoTemplates = useMemo(() => getMemoTemplates(t), [t]); - const [editingTemplate, setEditingTemplate] = useState(null); + const [editingTemplate, setEditingTemplate] = useState(null); const [draft, setDraft] = useState({ name: "", description: "", title: "", contentMarkdown: "", tags: "" }); - const startEditing = (template: SavedMemoTemplate) => { + const startEditing = (template: MemoTemplate) => { setEditingTemplate(template); setDraft({ name: template.name, @@ -72,8 +68,8 @@ export const TemplatesDialog = ({
- {savedTemplates.length > 0 && ( -
+ {savedTemplates.length > 0 ? ( +

{t("templates.myTemplates")}

{editingTemplate && (
@@ -107,9 +103,6 @@ export const TemplatesDialog = ({ >
{template.name} - - {t("templates.badgeCustom")} -
{template.description || template.title || t("templates.savedDescription")} @@ -135,32 +128,9 @@ export const TemplatesDialog = ({ ))}
+ ) : ( +

{t("templates.emptyMyTemplatesHint")}

)} -

{t("templates.builtIn")}

-
- {memoTemplates.map((template) => ( - - ))} -
{!canCreateMemo && (
{t("templates.unavailable")} diff --git a/apps/web/src/lib/app-helpers.ts b/apps/web/src/lib/app-helpers.ts index f9a617bb..fd143dec 100644 --- a/apps/web/src/lib/app-helpers.ts +++ b/apps/web/src/lib/app-helpers.ts @@ -39,115 +39,6 @@ export type NotebookNameDialogState = export type AppNoticeDialogState = { title: string; description: string }; -export type MemoTemplate = { - id: string; - title: string; - description: string; - contentMarkdown: string; - tags: string[]; - iconName?: "zap" | "calendar" | "checklist" | "book" | "sun" | "file"; - themeColor?: { - iconBg: string; - iconText: string; - badgeBg: string; - badgeText: string; - accentBorder: string; - }; -}; - -export const getMemoTemplates = (t: TFunction): MemoTemplate[] => [ - { - id: "quick-note", - title: t("templates.items.quickNote.title"), - description: t("templates.items.quickNote.description"), - contentMarkdown: t("templates.items.quickNote.contentMarkdown"), - tags: ["template", "quick-note"], - iconName: "zap", - themeColor: { - iconBg: "bg-amber-100/80 text-amber-700", - iconText: "text-amber-700", - badgeBg: "bg-amber-50 text-amber-800 border-amber-200/60", - badgeText: "text-amber-800", - accentBorder: "hover:border-amber-300/80", - }, - }, - { - id: "meeting", - title: t("templates.items.meeting.title"), - description: t("templates.items.meeting.description"), - contentMarkdown: t("templates.items.meeting.contentMarkdown"), - tags: ["template", "meeting"], - iconName: "calendar", - themeColor: { - iconBg: "bg-blue-100/80 text-blue-700", - iconText: "text-blue-700", - badgeBg: "bg-blue-50 text-blue-800 border-blue-200/60", - badgeText: "text-blue-800", - accentBorder: "hover:border-blue-300/80", - }, - }, - { - id: "weekly-review", - title: t("templates.items.weeklyReview.title"), - description: t("templates.items.weeklyReview.description"), - contentMarkdown: t("templates.items.weeklyReview.contentMarkdown"), - tags: ["template", "weekly-review"], - iconName: "checklist", - themeColor: { - iconBg: "bg-emerald-100/80 text-emerald-700", - iconText: "text-emerald-700", - badgeBg: "bg-emerald-50 text-emerald-800 border-emerald-200/60", - badgeText: "text-emerald-800", - accentBorder: "hover:border-emerald-300/80", - }, - }, - { - id: "reading", - title: t("templates.items.reading.title"), - description: t("templates.items.reading.description"), - contentMarkdown: t("templates.items.reading.contentMarkdown"), - tags: ["template", "reading"], - iconName: "book", - themeColor: { - iconBg: "bg-purple-100/80 text-purple-700", - iconText: "text-purple-700", - badgeBg: "bg-purple-50 text-purple-800 border-purple-200/60", - badgeText: "text-purple-800", - accentBorder: "hover:border-purple-300/80", - }, - }, - { - id: "okr", - title: t("templates.items.okr.title"), - description: t("templates.items.okr.description"), - contentMarkdown: t("templates.items.okr.contentMarkdown"), - tags: ["template", "okr"], - iconName: "sun", - themeColor: { - iconBg: "bg-rose-100/80 text-rose-700", - iconText: "text-rose-700", - badgeBg: "bg-rose-50 text-rose-800 border-rose-200/60", - badgeText: "text-rose-800", - accentBorder: "hover:border-rose-300/80", - }, - }, - { - id: "post-mortem", - title: t("templates.items.postMortem.title"), - description: t("templates.items.postMortem.description"), - contentMarkdown: t("templates.items.postMortem.contentMarkdown"), - tags: ["template", "post-mortem"], - iconName: "file", - themeColor: { - iconBg: "bg-indigo-100/80 text-indigo-700", - iconText: "text-indigo-700", - badgeBg: "bg-indigo-50 text-indigo-800 border-indigo-200/60", - badgeText: "text-indigo-800", - accentBorder: "hover:border-indigo-300/80", - }, - }, -]; - export const isTextEntryTarget = (target: EventTarget | null) => target instanceof HTMLElement && Boolean(target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .ProseMirror")); diff --git a/migrations/0030_unify_editable_note_templates.sql b/migrations/0030_unify_editable_note_templates.sql new file mode 100644 index 00000000..ad4cdb20 --- /dev/null +++ b/migrations/0030_unify_editable_note_templates.sql @@ -0,0 +1,51 @@ +PRAGMA foreign_keys = ON; + +-- Remove the untouched legacy project-weekly seed. If a user edited it, keep +-- it as an ordinary custom template alongside the new starter templates. +DELETE FROM memo_templates +WHERE id = workspace_id || '_template_project_weekly' + AND name = '项目周报模板' + AND description = '每周同步项目进展、风险与下一步计划' + AND title = '项目周报|第 {{周次}} 周' + AND updated_at = created_at; + +-- Existing workspaces have no persisted locale preference, so migrate them +-- with the historical Simplified Chinese catalog. New workspaces use the +-- request language in workspace-provisioning.ts. +WITH seed_catalog AS ( + SELECT + json_extract(value, '$.key') AS seed_key, + json_extract(value, '$.name') AS name, + json_extract(value, '$.description') AS description, + json_extract(value, '$.contentMarkdown') AS content_markdown, + json_extract(value, '$.tags') AS tags_json + FROM json_each('[ + {"key":"quick-note","name":"灵感速记","description":"快速捕捉闪念、临时灵感、资料链接与即刻行动。","contentMarkdown":"## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ","tags":["template","quick-note"]}, + {"key":"meeting","name":"会议纪要","description":"结构化记录议题背景、核心结论与带负责人的待办事项。","contentMarkdown":"# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n","tags":["template","meeting"]}, + {"key":"weekly-review","name":"周报与进展复盘","description":"梳理本周核心产出、风险卡点与下周关键优先级。","contentMarkdown":"# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n","tags":["template","weekly-review"]}, + {"key":"reading","name":"深度阅读卡片","description":"提炼核心观点、精妙摘录、个人理解与关联知识卡片。","contentMarkdown":"# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n","tags":["template","reading"]}, + {"key":"okr","name":"目标与任务拆解","description":"明确 OKR 目标、关键结果、里程碑与具体执行清单。","contentMarkdown":"# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n","tags":["template","okr"]}, + {"key":"post-mortem","name":"问题排查与复盘","description":"记录故障现象、根因分析 (5 Whys) 与防范机制。","contentMarkdown":"# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n","tags":["template","post-mortem"]} + ]') +) +INSERT OR IGNORE INTO memo_templates ( + id, + workspace_id, + name, + description, + title, + content_json, + content_markdown, + tags_json +) +SELECT + workspaces.id || '_template_' || seed_catalog.seed_key, + workspaces.id, + seed_catalog.name, + seed_catalog.description, + seed_catalog.name, + '{"type":"doc","content":[]}', + seed_catalog.content_markdown, + seed_catalog.tags_json +FROM workspaces +CROSS JOIN seed_catalog; diff --git a/packages/shared/package.json b/packages/shared/package.json index 26b6bc7a..e1d2d4f2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,6 +8,7 @@ "./ai-prompt-seeds": "./src/ai-prompt-seeds.ts", "./image-display": "./src/image-display.ts", "./i18n": "./src/i18n/index.ts", + "./memo-template-seeds": "./src/memo-template-seeds.ts", "./mathematics": "./src/mathematics.ts", "./mobile-editor": "./src/mobile-editor.ts", "./mobile-ui": "./src/mobile-ui.ts" diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 4e69c8fe..89f7486a 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1,3 +1,5 @@ +import { memoTemplateSeedTranslations } from "../memo-template-seeds"; + // Shared by the PWA and native mobile clients. export const enUS = { common: { @@ -1257,28 +1259,26 @@ export const enUS = { }, templates: { title: "Template Center", - description: "Start notes instantly with preset structures or customize your reusable templates.", - create: "New Custom Template", + description: "Create, edit, and reuse your note templates.", + create: "New Template", useTemplate: "New note from template", useThisTemplate: "Use Template", previewTemplate: "Preview", previewTitle: "Template Preview", createDescription: "Create a reusable template from scratch.", - myTemplates: "My Custom Templates", - myTemplatesSubtitle: "Saved personal templates ready to reuse and edit anytime", - builtIn: "Recommended Templates", - builtInSubtitle: "Ready-to-use standard structures to jumpstart your writing", - savedDescription: "Custom template saved from a note.", - emptyMyTemplates: "No custom templates yet", - emptyMyTemplatesHint: "Click \"New Custom Template\" above or save any note as a template directly in the editor.", + myTemplates: "Templates", + myTemplatesSubtitle: "Every template belongs to this workspace and can be edited or deleted", + savedDescription: "A reusable note template.", + emptyMyTemplates: "No templates yet", + emptyMyTemplatesHint: "Click \"New Template\" above or save any note as a template directly in the editor.", delete: "Delete template", deleteConfirmTitle: "Delete template \"{{name}}\"", deleteConfirmDescription: "Are you sure you want to delete this template? This operation cannot be undone.", saveAsTemplate: "Save as template", - saveAsTemplateSuccess: "Saved as custom template", + saveAsTemplateSuccess: "Saved as template", edit: "Edit template", - editTemplateTitle: "Edit Custom Template", - createTemplateTitle: "Create Custom Template", + editTemplateTitle: "Edit Template", + createTemplateTitle: "Create Template", name: "Template name", namePlaceholder: "e.g., Weekly Summary / Reading Card", descriptionField: "Template description", @@ -1295,40 +1295,7 @@ export const enUS = { templateSaved: "Template saved", templateCreated: "Template created", unavailable: "Select an available notebook before creating a note.", - badgeBuiltIn: "Built-in", - badgeCustom: "Custom", - items: { - quickNote: { - title: "Quick Spark", - description: "Capture fleeting thoughts, ideas, links, and immediate action items.", - contentMarkdown: "## 💡 Fleeting Thoughts\n\n- \n\n## 📌 Context & Notes\n\n\n\n## 🚀 Next Actions\n\n- [ ] ", - }, - meeting: { - title: "Meeting Minutes", - description: "Structured log for agenda, key decisions, and action items with owners.", - contentMarkdown: "# 📝 Meeting Minutes\n\n- **Time**:\n- **Host/Recorder**:\n- **Attendees**:\n\n---\n\n## 🎯 Goal\n\n- \n\n## 💬 Discussion & Decisions\n\n1. **[Topic 1]**\n - Points:\n - ✅ **Decision**:\n\n2. **[Topic 2]**\n - Points:\n - ✅ **Decision**:\n\n## 📋 Action Items\n\n- [ ] **[Owner]** Task description (Due: MM-DD)\n- [ ] **[Owner]** Task description (Due: MM-DD)\n", - }, - weeklyReview: { - title: "Weekly Review & Status", - description: "Summarize weekly highlights, blockers, and next week's key priorities.", - contentMarkdown: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n", - }, - reading: { - title: "Reading Note Card", - description: "Extract key takeaways, quotes, reflections, and connected concepts.", - contentMarkdown: "# 📖 Reading Note Card\n\n- **Book/Article**:\n- **Author/Source**:\n- **Rating**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Key Takeaway\n\n> \n\n## ✍️ Highlights & Quotes\n\n> [Quote content]\n> —— *Original Source*\n\n## 🧠 Personal Reflections\n\n- \n\n## 🔗 Action & Practice\n\n- [ ] **Action Plan**:\n", - }, - okr: { - title: "Goal & Task Breakdown", - description: "Define OKRs, Key Results, milestones, and task checklists.", - contentMarkdown: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n", - }, - postMortem: { - title: "Problem & Post-mortem", - description: "Document incident symptoms, 5-Whys root cause, and preventive measures.", - contentMarkdown: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n", - }, - }, + items: memoTemplateSeedTranslations("en-US"), }, pwa: { checkingTitle: "New version found", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index a4db5a7f..09e3f0a9 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1,3 +1,5 @@ +import { memoTemplateSeedTranslations } from "../memo-template-seeds"; + // Shared by the PWA and native mobile clients. export const zhCN = { common: { @@ -1257,28 +1259,26 @@ export const zhCN = { }, templates: { title: "模板中心", - description: "选择预设结构快捷创建笔记,或打造你的专属自定义模板。", - create: "新建自定义模板", + description: "创建、修改和复用你的笔记模板。", + create: "新建模板", useTemplate: "从模板新建笔记", useThisTemplate: "使用此模板新建", previewTemplate: "预览", previewTitle: "模板结构预览", createDescription: "从零开始创建一个可重复使用的模板。", - myTemplates: "我的自定义模板", - myTemplatesSubtitle: "已保存的专属笔记模板,可随时复用与修改", - builtIn: "内置推荐模板", - builtInSubtitle: "开箱即用的标准笔记结构,点击即可快捷生成", - savedDescription: "基于已保存笔记的自定义模板。", - emptyMyTemplates: "暂无自定义模板", - emptyMyTemplatesHint: "你可以点击上方「新建自定义模板」,或在笔记编辑器中将常用笔记另存为模板。", + myTemplates: "模板", + myTemplatesSubtitle: "所有模板都属于当前工作区,可以自由修改或删除", + savedDescription: "可重复使用的笔记模板。", + emptyMyTemplates: "暂无模板", + emptyMyTemplatesHint: "你可以点击上方「新建模板」,或在笔记编辑器中将常用笔记另存为模板。", delete: "删除模板", deleteConfirmTitle: "删除模板「{{name}}」", deleteConfirmDescription: "确定要删除该模板吗?此操作不可恢复。", saveAsTemplate: "另存为模板", - saveAsTemplateSuccess: "已成功保存为自定义模板", + saveAsTemplateSuccess: "已成功保存为模板", edit: "编辑模板", - editTemplateTitle: "编辑自定义模板", - createTemplateTitle: "新建自定义模板", + editTemplateTitle: "编辑模板", + createTemplateTitle: "新建模板", name: "模板名称", namePlaceholder: "例如:每周工作总结 / 论文阅读卡片", descriptionField: "模板说明", @@ -1295,40 +1295,7 @@ export const zhCN = { templateSaved: "模板已保存", templateCreated: "模板已创建", unavailable: "当前无法创建笔记,请先选择可用笔记本。", - badgeBuiltIn: "内置预设", - badgeCustom: "自定义", - items: { - quickNote: { - title: "灵感速记", - description: "快速捕捉闪念、临时灵感、资料链接与即刻行动。", - contentMarkdown: "## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ", - }, - meeting: { - title: "会议纪要", - description: "结构化记录议题背景、核心结论与带负责人的待办事项。", - contentMarkdown: "# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n", - }, - weeklyReview: { - title: "周报与进展复盘", - description: "梳理本周核心产出、风险卡点与下周关键优先级。", - contentMarkdown: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n", - }, - reading: { - title: "深度阅读卡片", - description: "提炼核心观点、精妙摘录、个人理解与关联知识卡片。", - contentMarkdown: "# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n", - }, - okr: { - title: "目标与任务拆解", - description: "明确 OKR 目标、关键结果、里程碑与具体执行清单。", - contentMarkdown: "# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n", - }, - postMortem: { - title: "问题排查与复盘", - description: "记录故障现象、根因分析 (5 Whys) 与防范机制。", - contentMarkdown: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n", - }, - }, + items: memoTemplateSeedTranslations("zh-CN"), }, pwa: { checkingTitle: "发现新版本", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 06dd2aa1..0a3e4be7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,7 @@ export * from "./markdown-image"; export * from "./memo-safety"; export * from "./memo-conflict"; export * from "./memo-content-style"; +export * from "./memo-template-seeds"; export * from "./note-links"; export * from "./notebooks"; export * from "./revision-diff"; diff --git a/packages/shared/src/memo-template-seeds.test.mjs b/packages/shared/src/memo-template-seeds.test.mjs new file mode 100644 index 00000000..4ebc3f71 --- /dev/null +++ b/packages/shared/src/memo-template-seeds.test.mjs @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_MEMO_TEMPLATE_SEEDS, + defaultMemoTemplateId, + localizeMemoTemplateSeed, +} from "./memo-template-seeds.ts"; + +describe("memo template seeds", () => { + test("provides six unique editable starter templates in both supported languages", () => { + expect(DEFAULT_MEMO_TEMPLATE_SEEDS).toHaveLength(6); + expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(6); + expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "zh-CN").title).toBe("灵感速记"); + expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "en-GB").title).toBe("Quick Spark"); + }); + + test("uses workspace-scoped deterministic ids", () => { + expect(defaultMemoTemplateId("ws_1", "meeting")).toBe("ws_1_template_meeting"); + }); +}); diff --git a/packages/shared/src/memo-template-seeds.ts b/packages/shared/src/memo-template-seeds.ts new file mode 100644 index 00000000..3a24c9f8 --- /dev/null +++ b/packages/shared/src/memo-template-seeds.ts @@ -0,0 +1,122 @@ +export type MemoTemplateSeedLocale = "zh-CN" | "en-US"; + +export type MemoTemplateSeedTranslation = { + title: string; + description: string; + contentMarkdown: string; +}; + +export type MemoTemplateSeed = { + key: "quick-note" | "meeting" | "weekly-review" | "reading" | "okr" | "post-mortem"; + translationKey: "quickNote" | "meeting" | "weeklyReview" | "reading" | "okr" | "postMortem"; + tag: string; + translations: Record; +}; + +const seed = ( + metadata: Omit, + zhCN: MemoTemplateSeedTranslation, + enUS: MemoTemplateSeedTranslation, +): MemoTemplateSeed => ({ + ...metadata, + translations: { "zh-CN": zhCN, "en-US": enUS }, +}); + +export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [ + seed( + { key: "quick-note", translationKey: "quickNote", tag: "quick-note" }, + { + title: "灵感速记", + description: "快速捕捉闪念、临时灵感、资料链接与即刻行动。", + contentMarkdown: "## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ", + }, + { + title: "Quick Spark", + description: "Capture fleeting thoughts, ideas, links, and immediate action items.", + contentMarkdown: "## 💡 Fleeting Thoughts\n\n- \n\n## 📌 Context & Notes\n\n\n\n## 🚀 Next Actions\n\n- [ ] ", + }, + ), + seed( + { key: "meeting", translationKey: "meeting", tag: "meeting" }, + { + title: "会议纪要", + description: "结构化记录议题背景、核心结论与带负责人的待办事项。", + contentMarkdown: "# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n", + }, + { + title: "Meeting Minutes", + description: "Structured log for agenda, key decisions, and action items with owners.", + contentMarkdown: "# 📝 Meeting Minutes\n\n- **Time**:\n- **Host/Recorder**:\n- **Attendees**:\n\n---\n\n## 🎯 Goal\n\n- \n\n## 💬 Discussion & Decisions\n\n1. **[Topic 1]**\n - Points:\n - ✅ **Decision**:\n\n2. **[Topic 2]**\n - Points:\n - ✅ **Decision**:\n\n## 📋 Action Items\n\n- [ ] **[Owner]** Task description (Due: MM-DD)\n- [ ] **[Owner]** Task description (Due: MM-DD)\n", + }, + ), + seed( + { key: "weekly-review", translationKey: "weeklyReview", tag: "weekly-review" }, + { + title: "周报与进展复盘", + description: "梳理本周核心产出、风险卡点与下周关键优先级。", + contentMarkdown: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n", + }, + { + title: "Weekly Review & Status", + description: "Summarize weekly highlights, blockers, and next week's key priorities.", + contentMarkdown: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n", + }, + ), + seed( + { key: "reading", translationKey: "reading", tag: "reading" }, + { + title: "深度阅读卡片", + description: "提炼核心观点、精妙摘录、个人理解与关联知识卡片。", + contentMarkdown: "# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n", + }, + { + title: "Reading Note Card", + description: "Extract key takeaways, quotes, reflections, and connected concepts.", + contentMarkdown: "# 📖 Reading Note Card\n\n- **Book/Article**:\n- **Author/Source**:\n- **Rating**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Key Takeaway\n\n> \n\n## ✍️ Highlights & Quotes\n\n> [Quote content]\n> —— *Original Source*\n\n## 🧠 Personal Reflections\n\n- \n\n## 🔗 Action & Practice\n\n- [ ] **Action Plan**:\n", + }, + ), + seed( + { key: "okr", translationKey: "okr", tag: "okr" }, + { + title: "目标与任务拆解", + description: "明确 OKR 目标、关键结果、里程碑与具体执行清单。", + contentMarkdown: "# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n", + }, + { + title: "Goal & Task Breakdown", + description: "Define OKRs, Key Results, milestones, and task checklists.", + contentMarkdown: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n", + }, + ), + seed( + { key: "post-mortem", translationKey: "postMortem", tag: "post-mortem" }, + { + title: "问题排查与复盘", + description: "记录故障现象、根因分析 (5 Whys) 与防范机制。", + contentMarkdown: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n", + }, + { + title: "Problem & Post-mortem", + description: "Document incident symptoms, 5-Whys root cause, and preventive measures.", + contentMarkdown: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n", + }, + ), +]; + +export const normalizeMemoTemplateSeedLocale = ( + locale: string | null | undefined, +): MemoTemplateSeedLocale => locale?.toLowerCase().startsWith("en") ? "en-US" : "zh-CN"; + +export const localizeMemoTemplateSeed = ( + templateSeed: MemoTemplateSeed, + locale: string | null | undefined, +): MemoTemplateSeedTranslation => templateSeed.translations[normalizeMemoTemplateSeedLocale(locale)]; + +export const defaultMemoTemplateId = (workspaceId: string, seedKey: string) => + `${workspaceId}_template_${seedKey}`; + +export const memoTemplateSeedTranslations = (locale: MemoTemplateSeedLocale) => + Object.fromEntries(DEFAULT_MEMO_TEMPLATE_SEEDS.map((item) => [ + item.translationKey, + item.translations[locale], + ])) as Record;