diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index aacf430b..07aee9a3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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)))); diff --git a/apps/web/src/lib/desktop-repository.test.mjs b/apps/web/src/lib/desktop-repository.test.mjs index 4cab6520..483251bd 100644 --- a/apps/web/src/lib/desktop-repository.test.mjs +++ b/apps/web/src/lib/desktop-repository.test.mjs @@ -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("富文本正文"); + }); +}); diff --git a/apps/web/src/lib/desktop-repository.ts b/apps/web/src/lib/desktop-repository.ts index 95396c9e..f4aaae47 100644 --- a/apps/web/src/lib/desktop-repository.ts +++ b/apps/web/src/lib/desktop-repository.ts @@ -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, }; diff --git a/apps/web/src/lib/local-mirror.test.mjs b/apps/web/src/lib/local-mirror.test.mjs index 7101399e..8279b48e 100644 --- a/apps/web/src/lib/local-mirror.test.mjs +++ b/apps/web/src/lib/local-mirror.test.mjs @@ -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" }); diff --git a/apps/web/src/lib/local-mirror.ts b/apps/web/src/lib/local-mirror.ts index fd15dcd0..062d90df 100644 --- a/apps/web/src/lib/local-mirror.ts +++ b/apps/web/src/lib/local-mirror.ts @@ -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), diff --git a/crates/desktop-sidecar/src/main.rs b/crates/desktop-sidecar/src/main.rs index 0c8b6ac3..c3ef0ebf 100644 --- a/crates/desktop-sidecar/src/main.rs +++ b/crates/desktop-sidecar/src/main.rs @@ -742,7 +742,7 @@ fn merge_memos(database: &Connection, params: &Value) -> Result { let mut markdowns = Vec::new(); let mut tags = Vec::::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>(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>(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 { titles.push(title); } } - markdowns.push(row.2); + markdowns.push(resolve_sidecar_merge_markdown(&row.2, &row.4, &row.5)?); let memo_tags: Vec = 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) -> .map(str::to_owned) } +fn resolve_sidecar_merge_markdown( + markdown: &str, + content_text: &str, + content_json: &str, +) -> Result { + 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 { let trash = bool_param(params, "trash", false); let q = params @@ -946,6 +991,7 @@ fn update_memo(database: &Connection, params: &Value) -> Result { 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()); + } } diff --git a/packages/shared/src/content.test.mjs b/packages/shared/src/content.test.mjs index 60950365..990e5616 100644 --- a/packages/shared/src/content.test.mjs +++ b/packages/shared/src/content.test.mjs @@ -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", () => { diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 74519f84..6a26a25b 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -238,6 +238,17 @@ export const docToMarkdown = (doc: unknown): string => { return markdownManager.serialize(stripEditorOnlyNodes(doc) as Parameters[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.