fix: preserve rich content when merging notes

This commit is contained in:
tianma-if committed 2026-08-02 10:42:35 +08:00
1 parent 4f0b02baa7
commit 0c4cfb3e81
8 files changed
+170 -10

No files matched your search

+9 -1
View File
@@ -10,6 +10,7 @@ import {
LoginSchema,
LoginDeviceSessionUpdateSchema,
markdownToDoc,
resolveMemoContentMarkdown,
resolveMergedMemoTitle,
isSuspiciousMemoOverwrite,
isMemoEditBindingValid,
@@ -4936,7 +4937,14 @@ const mergeMemosRecord = async (
.filter((row): row is MemoDetailRow => Boolean(row));
const notebookId = input.notebookId ?? ordered[0].notebook_id;
const title = resolveMergedMemoTitle(input.title, ordered);
const mergedMarkdown = ordered.map((memo) => memo.content_markdown).join("\n\n---\n\n");
const sourceMarkdown = ordered.map((memo) => {
const markdown = resolveMemoContentMarkdown(parseDoc(memo.content_json), memo.content_markdown);
if (!markdown.trim() && memo.content_text.trim()) {
throw new AppError("merge_content_unavailable", "One or more memo bodies could not be recovered safely.", 409);
}
return markdown;
});
const mergedMarkdown = sourceMarkdown.join("\n\n---\n\n");
const contentJson = markdownToDoc(mergedMarkdown);
const contentText = docToText(contentJson);
const tags = Array.from(new Set(ordered.flatMap((memo) => parseJsonArray(memo.tags_json))));
@@ -4,10 +4,14 @@ let lastRequest = null;
globalThis.window = {
location: { hostname: "notes.example.com", origin: "https://notes.example.com" },
dispatchEvent: () => true,
edgeeverDesktop: {
isAvailable: true,
sidecarRequest: async (method, params) => {
lastRequest = { method, params };
if (method === "memo.update") {
return { memo: { ...params, id: params.memoId, contentHash: "next-hash" } };
}
return { memos: [], totalCount: 0, nextCursor: null };
},
},
@@ -31,3 +35,19 @@ describe("desktop repository notebook filters", () => {
});
});
});
describe("desktop repository memo saves", () => {
test("serializes rich content to Markdown before sending it to the sidecar", async () => {
await createDesktopRepository().updateMemo(
{ id: "memo-1", revision: 2, contentHash: "base-hash" },
{
title: "Rich note",
contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "富文本正文" }] }] },
tags: [],
},
);
expect(lastRequest.method).toBe("memo.update");
expect(lastRequest.params.contentMarkdown).toContain("富文本正文");
});
});
+7 -3
View File
@@ -6,7 +6,7 @@ import type {
DesktopRpcParams,
DesktopRpcResponses,
} from "@edgeever/shared";
import { docToText, type MemoDetail } from "@edgeever/shared";
import { docToMarkdown, docToText, type MemoDetail } from "@edgeever/shared";
import type { EdgeEverRepository } from "@/lib/repository";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { createStagedResourceListItem, mapMarkdownResourceUrls, mapTiptapResourceUrls, toApiResourceUrl, toDesktopResourceUrl } from "@/lib/desktop-resources";
@@ -214,13 +214,17 @@ export const createDesktopRepository = (): EdgeEverRepository => ({
},
updateMemo: async (memo: MemoDetail, input) => {
const portableContentJson = mapTiptapResourceUrls(input.contentJson, toApiResourceUrl);
const contentMarkdown = input.contentMarkdown === undefined
? docToMarkdown(portableContentJson)
: mapMarkdownResourceUrls(input.contentMarkdown, toApiResourceUrl);
const rpcParams: DesktopMemoUpdateParams & { contentText: string } = {
memoId: memo.id,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
title: input.title,
contentJson: mapTiptapResourceUrls(input.contentJson, toApiResourceUrl),
contentMarkdown: mapMarkdownResourceUrls(input.contentMarkdown, toApiResourceUrl),
contentJson: portableContentJson,
contentMarkdown,
contentText: docToText(input.contentJson),
tags: input.tags,
};
+34
View File
@@ -13,6 +13,7 @@ const {
listLocalTags,
listLocalMemos,
putLocalTemplate,
putLocalMemo,
putLocalMemoUpdate,
applyLocalTagRename,
applyLocalMemoMove,
@@ -82,6 +83,7 @@ describe("local mirror", () => {
expect(updated.id).toBe(memo.id);
expect((await getLocalMemo(scope, memo.id))?.title).toBe("Updated locally");
expect((await getLocalMemo(scope, memo.id))?.tags).toEqual(["updated"]);
expect((await getLocalMemo(scope, memo.id))?.contentMarkdown).toContain("Changed");
});
test("caches templates inside the same local data scope", async () => {
@@ -171,6 +173,38 @@ describe("local mirror", () => {
expect(merged?.title).toBe("手动设置的标题");
});
test("recovers rich content when source Markdown copies are empty", async () => {
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
const first = await createLocalMemo(scope, { notebookId: "inbox", title: "First" });
const second = await createLocalMemo(scope, { notebookId: "inbox", title: "Second" });
first.contentJson = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "第一篇真实正文" }] }] };
first.contentText = "第一篇真实正文";
second.contentJson = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "第二篇真实正文" }] }] };
second.contentText = "第二篇真实正文";
await putLocalMemo(scope, first);
await putLocalMemo(scope, second);
const merged = await mergeLocalMemos(scope, { memoIds: [first.id, second.id] });
expect(merged?.contentMarkdown).toContain("第一篇真实正文");
expect(merged?.contentMarkdown).toContain("第二篇真实正文");
expect(merged?.contentText).toContain("第一篇真实正文");
expect((await getLocalMemo(scope, first.id))?.isDeleted).toBe(true);
});
test("cancels a merge before deleting sources when cached content is inconsistent", async () => {
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
const unsafe = await createLocalMemo(scope, { notebookId: "inbox", title: "Unsafe" });
const second = await createLocalMemo(scope, { notebookId: "inbox", title: "Second", contentMarkdown: "正常正文" });
unsafe.contentText = "缓存声称这里有正文";
await putLocalMemo(scope, unsafe);
await expect(mergeLocalMemos(scope, { memoIds: [unsafe.id, second.id] }))
.rejects.toThrow("Merge was cancelled");
expect((await getLocalMemo(scope, unsafe.id))?.isDeleted).toBe(false);
expect((await getLocalMemo(scope, second.id))?.isDeleted).toBe(false);
});
test("caches memo revisions by account scope", async () => {
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
const memo = await createLocalMemo(scope, { notebookId: "inbox" });
+10 -3
View File
@@ -1,4 +1,4 @@
import { createExcerpt, docToText, markdownToDoc, resolveMergedMemoTitle, type MemoDetail, type MemoRevision, type MemoSummary, type MemoTemplate, type Notebook, type ResourceListItem, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import { createExcerpt, docToMarkdown, docToText, markdownToDoc, resolveMemoContentMarkdown, resolveMergedMemoTitle, type MemoDetail, type MemoRevision, type MemoSummary, type MemoTemplate, type Notebook, type ResourceListItem, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import type { MemoFilterMode, MemoSortMode } from "@/lib/app-helpers";
import { api, type SyncChangesResponse } from "@/lib/api";
import { localDb, type LocalMemo, type LocalNotebook, type LocalResource, type LocalRevision } from "@/lib/local-db";
@@ -301,7 +301,7 @@ export const putLocalMemoUpdate = async (
excerpt: createExcerpt(contentText),
tags: input.tags,
contentJson: input.contentJson,
contentMarkdown: input.contentMarkdown ?? memo.contentMarkdown,
contentMarkdown: input.contentMarkdown ?? docToMarkdown(input.contentJson),
contentText,
updatedAt: new Date().toISOString(),
};
@@ -560,7 +560,14 @@ export const applyLocalEmptyTrash = async (scope: string) => {
export const mergeLocalMemos = async (scope: string, input: { memoIds: string[]; notebookId?: string; title?: string }) => {
const sources = (await Promise.all(input.memoIds.map((memoId) => getLocalMemo(scope, memoId)))).filter((memo): memo is MemoDetail => Boolean(memo));
if (sources.length < 2) return null;
const contentMarkdown = sources.map((memo) => memo.contentMarkdown).filter(Boolean).join("\n\n---\n\n");
const sourceMarkdown = sources.map((memo) => {
const markdown = resolveMemoContentMarkdown(memo.contentJson, memo.contentMarkdown);
if (!markdown.trim() && memo.contentText.trim()) {
throw new Error("Source note content could not be recovered safely. Merge was cancelled.");
}
return markdown;
});
const contentMarkdown = sourceMarkdown.join("\n\n---\n\n");
const memo = await createLocalMemo(scope, {
notebookId: input.notebookId ?? sources[0]!.notebookId,
title: resolveMergedMemoTitle(input.title, sources),
+72 -2
View File
@@ -742,7 +742,7 @@ fn merge_memos(database: &Connection, params: &Value) -> Result<Value, String> {
let mut markdowns = Vec::new();
let mut tags = Vec::<String>::new();
for id in &ids {
let row = database.query_row("SELECT m.title, m.notebook_id, c.content_markdown, m.tags_json FROM memos m JOIN memo_contents c ON c.memo_id = m.id WHERE m.id = ?1 AND m.is_deleted = 0", [id], |row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?))).map_err(|e| e.to_string())?;
let row = database.query_row("SELECT m.title, m.notebook_id, c.content_markdown, m.tags_json, c.content_text, c.content_json FROM memos m JOIN memo_contents c ON c.memo_id = m.id WHERE m.id = ?1 AND m.is_deleted = 0", [id], |row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).map_err(|e| e.to_string())?;
if first_notebook_id.is_none() {
first_notebook_id = Some(row.1);
}
@@ -751,7 +751,7 @@ fn merge_memos(database: &Connection, params: &Value) -> Result<Value, String> {
titles.push(title);
}
}
markdowns.push(row.2);
markdowns.push(resolve_sidecar_merge_markdown(&row.2, &row.4, &row.5)?);
let memo_tags: Vec<String> = serde_json::from_str(&row.3).unwrap_or_default();
tags.extend(memo_tags);
}
@@ -801,6 +801,51 @@ fn resolve_custom_merge_title<'a>(titles: impl IntoIterator<Item = &'a str>) ->
.map(str::to_owned)
}
fn resolve_sidecar_merge_markdown(
markdown: &str,
content_text: &str,
content_json: &str,
) -> Result<String, String> {
if !markdown.trim().is_empty() {
return Ok(markdown.to_owned());
}
if !content_text.trim().is_empty() {
return Ok(content_text.to_owned());
}
let content: Value = serde_json::from_str(content_json).unwrap_or(Value::Null);
if sidecar_doc_has_non_text_content(&content) {
return Err(
"Source note content could not be recovered safely. Merge was cancelled.".to_owned(),
);
}
Ok(String::new())
}
fn sidecar_doc_has_non_text_content(value: &Value) -> bool {
let node_type = value.get("type").and_then(Value::as_str).unwrap_or("");
if matches!(
node_type,
"image"
| "table"
| "codeBlock"
| "bulletList"
| "orderedList"
| "blockquote"
| "horizontalRule"
| "edgeeverThemeBlock"
) {
return true;
}
value
.get("content")
.and_then(Value::as_array)
.is_some_and(|children| children.iter().any(sidecar_doc_has_non_text_content))
}
fn list_memos(database: &Connection, params: &Value) -> Result<Value, String> {
let trash = bool_param(params, "trash", false);
let q = params
@@ -946,6 +991,7 @@ fn update_memo(database: &Connection, params: &Value) -> Result<Value, String> {
let markdown = params
.get("contentMarkdown")
.and_then(Value::as_str)
.or_else(|| previous.get("contentMarkdown").and_then(Value::as_str))
.unwrap_or("")
.to_owned();
let text = params
@@ -1574,4 +1620,28 @@ mod tests {
);
assert_eq!(resolve_custom_merge_title(["无标题笔记", " "]), None);
}
#[test]
fn merge_content_falls_back_to_stored_text_when_markdown_is_empty() {
assert_eq!(
resolve_sidecar_merge_markdown("", "正文仍然存在", r#"{"type":"doc","content":[]}"#)
.unwrap(),
"正文仍然存在"
);
assert_eq!(
resolve_sidecar_merge_markdown(
"**保留格式**",
"保留格式",
r#"{"type":"doc","content":[]}"#
)
.unwrap(),
"**保留格式**"
);
assert!(resolve_sidecar_merge_markdown(
"",
"",
r#"{"type":"doc","content":[{"type":"image","attrs":{"src":"image.png"}}]}"#
)
.is_err());
}
}
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { countMemoCharacters, docToMarkdown, docToText, markdownToDoc, resolveMemoContentDoc, resolveMergedMemoTitle } from "./content.ts";
import { countMemoCharacters, docToMarkdown, docToText, markdownToDoc, resolveMemoContentDoc, resolveMemoContentMarkdown, resolveMergedMemoTitle } from "./content.ts";
describe("merged memo title", () => {
test("prefers an explicit title, then the first custom source title", () => {
@@ -80,6 +80,12 @@ describe("legacy Markdown body recovery", () => {
expect(docToText(resolved)).toContain(markdown);
});
test("recovers Markdown from rich content when the compatibility copy is empty", () => {
const richContent = markdownToDoc("这段正文只保存在富文本 JSON 中。");
expect(resolveMemoContentMarkdown(richContent, "")).toContain("这段正文只保存在富文本 JSON 中。");
});
});
describe("Nested list Markdown conversion", () => {
+11
View File
@@ -238,6 +238,17 @@ export const docToMarkdown = (doc: unknown): string => {
return markdownManager.serialize(stripEditorOnlyNodes(doc) as Parameters<typeof markdownManager.serialize>[0]);
};
/**
* Returns the best complete Markdown representation available for a memo.
* Some older rich-editor saves populated contentJson while leaving the
* Markdown compatibility copy empty, so merge/export callers must not trust
* contentMarkdown alone.
*/
export const resolveMemoContentMarkdown = (
contentJson: TiptapDoc | null | undefined,
contentMarkdown: string | null | undefined,
) => docToMarkdown(resolveMemoContentDoc(contentJson, contentMarkdown));
/**
* Theme blocks are richer editor-only nodes. Markdown has no portable equivalent,
* so exports keep their text as a quoted section instead of silently dropping it.