Checkpoint current workspace

This commit is contained in:
lix-2026
2026-05-02 06:25:26 +08:00
parent 015ad5bedd
commit 98b6360595
88 changed files with 40217 additions and 178 deletions
+452 -19
View File
@@ -9150,6 +9150,10 @@ fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
"todo" | "task" => EditorBlockType::Todo,
"quote" | "blockquote" => EditorBlockType::Quote,
"code" | "code_block" | "code-block" => EditorBlockType::CodeBlock,
"divider" | "horizontal_rule" | "horizontalrule" => EditorBlockType::Divider,
"table" => EditorBlockType::Table,
"image" | "picture" => EditorBlockType::Image,
"toc" | "toc_node" | "tocnode" => EditorBlockType::Toc,
"page_reference" => EditorBlockType::PageReference,
"block_reference" => EditorBlockType::BlockReference,
_ => EditorBlockType::Paragraph,
@@ -9174,6 +9178,11 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
.and_then(Value::as_u64)
.and_then(|value| u8::try_from(value).ok())
.map(|value| value.clamp(1, 6));
props.collapsed = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("collapsed"))
.and_then(Value::as_bool);
}
if matches!(
normalize_editor_block_type_for_save(&raw_type),
@@ -9196,6 +9205,30 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
if let Some(tiptap_table) = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("tiptapTable"))
.cloned()
{
props.extra.insert("tiptapTable".into(), tiptap_table);
}
if let Some(tiptap_image) = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("tiptapImage"))
.cloned()
{
props.extra.insert("tiptapImage".into(), tiptap_image);
}
if let Some(tiptap_toc) = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("tiptapTocNode").or_else(|| map.get("tiptapToc")))
.cloned()
{
props.extra.insert("tiptapTocNode".into(), tiptap_toc);
}
let text = read_trimmed_string_field(block, &["content"])
.filter(|value| !value.is_empty())
.unwrap_or_else(|| extract_inline_text(block));
@@ -9265,17 +9298,83 @@ fn normalize_save_editor_document(
}
fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
let mut props = serde_json::Map::new();
match block.block_type {
EditorBlockType::Heading => Some(json!({
"level": block.props.heading_level.unwrap_or(1),
})),
EditorBlockType::Todo => Some(json!({
"checked": block.props.checked.unwrap_or(false),
})),
EditorBlockType::CodeBlock => Some(json!({
"language": block.props.language,
})),
_ => None,
EditorBlockType::Heading => {
props.insert(
"level".into(),
json!(block.props.heading_level.unwrap_or(1)),
);
if let Some(collapsed) = block.props.collapsed {
props.insert("collapsed".into(), json!(collapsed));
}
}
EditorBlockType::Todo => {
props.insert(
"checked".into(),
json!(block.props.checked.unwrap_or(false)),
);
}
EditorBlockType::CodeBlock => {
props.insert("language".into(), json!(block.props.language));
}
EditorBlockType::Table => {
if let Some(tiptap_table) = block.props.extra.get("tiptapTable") {
props.insert("tiptapTable".into(), tiptap_table.clone());
}
}
EditorBlockType::Image => {
if let Some(tiptap_image) = block.props.extra.get("tiptapImage") {
props.insert("tiptapImage".into(), tiptap_image.clone());
if let Some(attrs) = tiptap_image.get("attrs").and_then(Value::as_object) {
for key in ["src", "alt", "title"] {
if let Some(value) = attrs.get(key) {
props.insert(key.into(), value.clone());
}
}
}
}
}
EditorBlockType::Toc => {
if let Some(tiptap_toc) = block.props.extra.get("tiptapTocNode") {
props.insert("tiptapTocNode".into(), tiptap_toc.clone());
}
}
_ => {}
}
if let Some(text_align) = block
.props
.extra
.get("textAlign")
.or_else(|| block.props.extra.get("text_align"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| matches!(*value, "left" | "center" | "right" | "justify"))
{
props.insert("textAlign".into(), json!(text_align));
}
if props.is_empty() {
None
} else {
Some(Value::Object(props))
}
}
fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
match block.block_type {
EditorBlockType::Paragraph => "paragraph",
EditorBlockType::Heading => "heading",
EditorBlockType::BulletListItem => "bullet_list_item",
EditorBlockType::NumberedListItem => "numbered_list_item",
EditorBlockType::Quote => "blockquote",
EditorBlockType::Todo => "todo",
EditorBlockType::CodeBlock => "code_block",
EditorBlockType::Divider => "divider",
EditorBlockType::Table => "table",
EditorBlockType::Image => "image",
EditorBlockType::Toc => "toc",
EditorBlockType::PageReference => "page_reference",
EditorBlockType::BlockReference => "block_reference",
}
}
@@ -9292,6 +9391,54 @@ fn legacy_text_from_editor_block(block: &EditorBlock) -> String {
}
fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value {
fn block_to_legacy_value(block: &EditorBlock, document: &EditorBlockDocument) -> Value {
let children = block
.child_block_ids
.iter()
.filter_map(|child_id| {
document
.blocks
.iter()
.find(|candidate| &candidate.block_id == child_id)
})
.map(|child| block_to_legacy_value(child, document))
.collect::<Vec<Value>>();
let mut value = json!({
"id": block.block_id,
"type": legacy_type_from_editor_block(block),
"props": legacy_props_from_editor_block(block),
"content": if matches!(block.block_type, EditorBlockType::Divider) {
Value::Array(Vec::new())
} else {
Value::String(legacy_text_from_editor_block(block))
},
});
if !children.is_empty() {
if let Value::Object(map) = &mut value {
map.insert("children".into(), Value::Array(children));
}
}
value
}
fn mark_block_tree_seen(
block: &EditorBlock,
document: &EditorBlockDocument,
seen: &mut std::collections::BTreeSet<String>,
) {
seen.insert(block.block_id.clone());
for child_id in &block.child_block_ids {
if let Some(child) = document
.blocks
.iter()
.find(|candidate| &candidate.block_id == child_id)
{
mark_block_tree_seen(child, document, seen);
}
}
}
let mut ordered = Vec::<&EditorBlock>::new();
let mut seen = std::collections::BTreeSet::<String>::new();
for root_block_id in &document.root_block_ids {
@@ -9300,7 +9447,7 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
.iter()
.find(|block| &block.block_id == root_block_id)
{
seen.insert(block.block_id.clone());
mark_block_tree_seen(block, document, &mut seen);
ordered.push(block);
}
}
@@ -9313,14 +9460,7 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
Value::Array(
ordered
.into_iter()
.map(|block| {
json!({
"id": block.block_id,
"type": block.block_type,
"props": legacy_props_from_editor_block(block),
"content": legacy_text_from_editor_block(block),
})
})
.map(|block| block_to_legacy_value(block, document))
.collect(),
)
}
@@ -10607,6 +10747,299 @@ mod tests {
);
}
#[test]
fn documents_save_command_plan_preserves_horizontal_rule_as_divider() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_divider".into(),
idempotency_key: Some("idem_save_divider".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [
{
"type": "horizontalRule",
"attrs": {
"blockId": "divider_1"
}
}
]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存分割线".into()),
refs: vec!["phase-e-divider".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save horizontalRule plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
Some(&json!("divider"))
);
assert_eq!(
plan.args_json.pointer("/content/0/type"),
Some(&json!("divider"))
);
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!([]))
);
}
#[test]
fn documents_save_command_plan_preserves_tiptap_image() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_image".into(),
idempotency_key: Some("idem_save_image".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "image",
"attrs": {
"src": "/api/editor/image-placeholder.svg",
"alt": "E24 图片占位",
"title": "E24 图片"
}
}]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存图片".into()),
refs: vec!["phase-e-image".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save image plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
Some(&json!("image"))
);
assert_eq!(
plan.args_json.pointer("/content/0/type"),
Some(&json!("image"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/tiptapImage/type"),
Some(&json!("image"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/src"),
Some(&json!("/api/editor/image-placeholder.svg"))
);
}
#[test]
fn documents_save_command_plan_preserves_tiptap_table() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_table".into(),
idempotency_key: Some("idem_save_table".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "table",
"attrs": { "blockId": "table_1" },
"content": [{
"type": "tableRow",
"content": [{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
"content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "A1" }] }]
}]
}]
}]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存简单表格".into()),
refs: vec!["phase-e-table".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save table plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
Some(&json!("table"))
);
assert_eq!(
plan.args_json.pointer("/content/0/type"),
Some(&json!("table"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/tiptapTable/type"),
Some(&json!("table"))
);
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("A1"))
);
}
#[test]
fn documents_save_command_plan_preserves_nested_list_children() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_nested_list".into(),
idempotency_key: Some("idem_save_nested_list".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"attrs": { "blockId": "parent" },
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "E19 parent" }] },
{ "type": "bulletList", "content": [{
"type": "listItem",
"attrs": { "blockId": "child" },
"content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "E19 child" }] }]
}]}
]
}]
}]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存嵌套列表".into()),
refs: vec!["phase-e-indent".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save nested list plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/rootBlockIds"),
Some(&json!(["parent"]))
);
assert_eq!(
plan.args_json
.pointer("/editorDocument/blocks/0/childBlockIds"),
Some(&json!(["child"]))
);
assert_eq!(
plan.args_json.pointer("/content/0/children/0/type"),
Some(&json!("bullet_list_item"))
);
assert_eq!(
plan.args_json.pointer("/content/0/children/0/content"),
Some(&json!("E19 child"))
);
assert_eq!(plan.args_json.pointer("/content/1"), None);
}
#[test]
fn page_body_save_command_plan_uses_canonical_page_command() {
let plan = execute_runtime_input(RuntimeInput::Command {