fix: preserve rich content when merging notes
This commit is contained in:
1 parent
4f0b02baa7
commit
0c4cfb3e81
8 files changed
+170
-10
No files matched your search
@@ -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("富文本正文");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user