fix: restrict automatic titles to leading H1
This commit is contained in:
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()
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in new issue
Block a user