Checkpoint current workspace
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user