fix: derive note titles from the first H1

This commit is contained in:
tianma-if committed 2026-08-28 17:41:54 +08:00
1 parent af1085e020
commit 3316b158d1
5 files changed
+49 -22

No files matched your search

@@ -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
}
}
@@ -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")
+4 -4
View File
@@ -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", () => {
+4 -8
View File
@@ -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<string, unknown> };
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;
+35
View File
@@ -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}`;