fix: restrict automatic titles to leading H1

This commit is contained in:
tianma-if committed 2026-08-28 17:52:29 +08:00
1 parent 3316b158d1
commit 475f87e24e
5 files changed
+67 -20

No files matched your search

@@ -64,13 +64,16 @@ final class MemoEditViewModel {
let text = Self.nodeText(block)
.split(whereSeparator: { $0.isWhitespace })
.joined(separator: " ")
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
if text.isEmpty {
let type = block["type"] as? String
if type == "paragraph" || type == "heading" { continue }
}
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\",\"content\":[{\"type\":\"text\",\"text\":\"Intro\"}]},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}"
json: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}"
)
XCTAssertEqual(model.title, "Product plan")
@@ -63,6 +63,16 @@ final class MemoEditViewModelTests: XCTestCase {
XCTAssertEqual(reopened.title, "Renamed heading")
}
func testDoesNotDeriveTitleFromH1AfterBodyContent() {
let model = MemoEditViewModel()
model.beginTitleDerivationSession()
model.applyEditorPayload(
markdown: "Intro\n\n# 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, "")
}
func testDoesNotDeriveTitleAfterManualTitleEdit() {
let model = MemoEditViewModel()
model.beginTitleDerivationSession()
+4 -4
View File
@@ -6,22 +6,22 @@ describe("deriveMemoTitleFromContent", () => {
expect(deriveMemoTitleFromContent(markdownToDoc("\n# Product plan \n\nBody"))).toBe("Product plan");
});
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 a later H1 when body content comes first", () => {
expect(deriveMemoTitleFromContent(markdownToDoc("Intro\n\n# Product plan"))).toBeNull();
});
test("does not use lower-level headings", () => {
expect(deriveMemoTitleFromContent(markdownToDoc("## Product plan"))).toBeNull();
});
test("uses the first H1 after a non-text block", () => {
test("does not skip a meaningful non-text block before the H1", () => {
expect(deriveMemoTitleFromContent({
type: "doc",
content: [
{ type: "image", attrs: { src: "/cover.png" } },
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Product plan" }] },
],
})).toBe("Product plan");
})).toBeNull();
});
test("limits generated titles to the persisted title length", () => {
+8 -4
View File
@@ -77,8 +77,9 @@ const nodeText = (node: unknown): string => {
};
/**
* 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.
* Derives a note title only when the first meaningful top-level block is an
* H1. Empty text blocks before it are ignored; body content prevents a later
* section heading from unexpectedly renaming the note.
*/
export const deriveMemoTitleFromContent = (doc: unknown): string | null => {
if (!doc || typeof doc !== "object") return null;
@@ -89,9 +90,12 @@ 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 (node.type === "heading" && node.attrs?.level === 1 && text) {
return text.slice(0, MEMO_TITLE_MAX_LENGTH);
if (!text) {
if (node.type === "paragraph" || node.type === "heading") continue;
return null;
}
if (node.type !== "heading" || node.attrs?.level !== 1) return null;
return text.slice(0, MEMO_TITLE_MAX_LENGTH);
}
return null;
+35 -5
View File
@@ -177,8 +177,38 @@ 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()}`;
test("derives the complete title when the first meaningful block is an H1", 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']");
try {
await editor.click();
await page.keyboard.type("# ");
await page.keyboard.insertText(derivedTitle);
await expect(page.getByPlaceholder("无标题")).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("does not derive the title from an H1 after body paragraphs", async ({ page }) => {
const sectionHeading = `后置一级标题 ${Date.now()}`;
const createResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/v1/memos",
);
@@ -197,15 +227,15 @@ test.describe("new memo synchronization", () => {
await page.keyboard.insertText("第二段正文");
await page.keyboard.press("Enter");
await page.keyboard.type("# ");
await page.keyboard.insertText(derivedTitle);
await page.keyboard.insertText(sectionHeading);
await expect(titleInput).toHaveValue(derivedTitle);
await expect(titleInput).toHaveValue("");
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);
}).toBe("无标题笔记");
} finally {
await page.request.delete(`/api/v1/memos/${created.memo.id}`);
await page.request.delete(`/api/v1/memos/${created.memo.id}?permanent=1`);