From 3316b158d18600216628efd9d0dd94666201df2d Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 28 Aug 2026 17:41:54 +0800 Subject: [PATCH] fix: derive note titles from the first H1 --- .../Workspace/MemoEditViewModel.swift | 14 +++----- .../MemoEditViewModelTests.swift | 2 +- packages/shared/src/content-title.test.mjs | 8 ++--- packages/shared/src/content.ts | 12 +++---- tests/e2e/new-memo-sync.spec.ts | 35 +++++++++++++++++++ 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift index 173c42ac..859115e6 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift @@ -64,17 +64,13 @@ final class MemoEditViewModel { let text = Self.nodeText(block) .split(whereSeparator: { $0.isWhitespace }) .joined(separator: " ") - if text.isEmpty { - let type = block["type"] as? String - if type == "paragraph" || type == "heading" { continue } + if block["type"] as? String == "heading", + let attrs = block["attrs"] as? [String: Any], + (attrs["level"] as? NSNumber)?.intValue == 1, + !text.isEmpty { + title = String(text.prefix(160)) return } - guard block["type"] as? String == "heading", - let attrs = block["attrs"] as? [String: Any], - (attrs["level"] as? NSNumber)?.intValue == 1 - else { return } - title = String(text.prefix(160)) - return } } diff --git a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift index 9feee2ee..da77e4ad 100644 --- a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift +++ b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift @@ -43,7 +43,7 @@ final class MemoEditViewModelTests: XCTestCase { model.beginTitleDerivationSession() model.applyEditorPayload( markdown: "# Product plan\n\nBody", - json: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}" + json: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Intro\"}]},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}" ) XCTAssertEqual(model.title, "Product plan") diff --git a/packages/shared/src/content-title.test.mjs b/packages/shared/src/content-title.test.mjs index 0a820f10..71599cad 100644 --- a/packages/shared/src/content-title.test.mjs +++ b/packages/shared/src/content-title.test.mjs @@ -6,22 +6,22 @@ describe("deriveMemoTitleFromContent", () => { expect(deriveMemoTitleFromContent(markdownToDoc("\n# Product plan \n\nBody"))).toBe("Product plan"); }); - test("does not use a later H1 when body content comes first", () => { - expect(deriveMemoTitleFromContent(markdownToDoc("Intro\n\n# Product plan"))).toBeNull(); + test("uses the first H1 even when body content comes first", () => { + expect(deriveMemoTitleFromContent(markdownToDoc("Intro\n\n# Product plan"))).toBe("Product plan"); }); test("does not use lower-level headings", () => { expect(deriveMemoTitleFromContent(markdownToDoc("## Product plan"))).toBeNull(); }); - test("does not skip meaningful non-text blocks before the H1", () => { + test("uses the first H1 after a non-text block", () => { expect(deriveMemoTitleFromContent({ type: "doc", content: [ { type: "image", attrs: { src: "/cover.png" } }, { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Product plan" }] }, ], - })).toBeNull(); + })).toBe("Product plan"); }); test("limits generated titles to the persisted title length", () => { diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 9c55094a..a50d6879 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -77,9 +77,8 @@ const nodeText = (node: unknown): string => { }; /** - * Derives a note title only when the first non-empty top-level content block is - * an H1. Callers own the one-shot edit-session policy; this helper only - * determines whether the current document is eligible. + * Derives a note title from the first non-empty top-level H1. Callers own the + * one-shot edit-session policy; this helper only finds the heading text. */ export const deriveMemoTitleFromContent = (doc: unknown): string | null => { if (!doc || typeof doc !== "object") return null; @@ -90,12 +89,9 @@ export const deriveMemoTitleFromContent = (doc: unknown): string | null => { if (!value || typeof value !== "object") continue; const node = value as { type?: unknown; attrs?: Record }; const text = nodeText(node).replace(/\s+/g, " ").trim(); - if (!text) { - if (node.type === "paragraph" || node.type === "heading") continue; - return null; + if (node.type === "heading" && node.attrs?.level === 1 && text) { + return text.slice(0, MEMO_TITLE_MAX_LENGTH); } - if (node.type !== "heading" || node.attrs?.level !== 1) return null; - return text.slice(0, MEMO_TITLE_MAX_LENGTH); } return null; diff --git a/tests/e2e/new-memo-sync.spec.ts b/tests/e2e/new-memo-sync.spec.ts index a3f9a684..5093b433 100644 --- a/tests/e2e/new-memo-sync.spec.ts +++ b/tests/e2e/new-memo-sync.spec.ts @@ -177,6 +177,41 @@ const finishSyncAndVerifyReload = async ( }; test.describe("new memo synchronization", () => { + test("derives the empty title from a complete H1 after body paragraphs", async ({ page }) => { + const derivedTitle = `后置一级标题 ${Date.now()}`; + const createResponsePromise = page.waitForResponse( + (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/v1/memos", + ); + + await page.goto("/"); + await page.getByRole("button", { name: "新建笔记", exact: true }).click(); + const createResponse = await createResponsePromise; + const created = await createResponse.json() as { memo: { id: string } }; + const editor = page.locator(".ProseMirror[contenteditable='true']"); + const titleInput = page.getByPlaceholder("无标题"); + + try { + await editor.click(); + await page.keyboard.insertText("第一段正文"); + await page.keyboard.press("Enter"); + await page.keyboard.insertText("第二段正文"); + await page.keyboard.press("Enter"); + await page.keyboard.type("# "); + await page.keyboard.insertText(derivedTitle); + + await expect(titleInput).toHaveValue(derivedTitle); + await expect.poll(async () => { + const response = await page.request.get(`/api/v1/memos/${created.memo.id}`); + if (!response.ok()) return null; + const body = await response.json() as { memo: { title: string | null } }; + return body.memo.title; + }).toBe(derivedTitle); + } finally { + await page.request.delete(`/api/v1/memos/${created.memo.id}`); + await page.request.delete(`/api/v1/memos/${created.memo.id}?permanent=1`); + } + }); + test("rebases a second autosave after memo creation reaches cloud revision 1", async ({ page }) => { const marker = `${Date.now()}-draft-only`; const title = `E2E create race ${marker}`;