20260513 mindmap优化01

This commit is contained in:
lix-2026
2026-05-13 22:43:16 +08:00
parent 17c003976b
commit b4a452a8b7
89 changed files with 11557 additions and 707 deletions
+693 -38
View File
@@ -13,10 +13,11 @@ use core_protocol::{
DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand,
EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand,
GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge,
KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode,
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
KernelNodeType, KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind,
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
@@ -5547,7 +5548,7 @@ fn build_mindmap_kernel_projection_result(
associative_lines: collect_mindmap_associative_lines(data),
layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"])
.unwrap_or_else(|| json!("logicalStructure")),
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("classic")),
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")),
view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})),
capabilities: MindmapKernelCapabilities {
can_edit: true,
@@ -5564,6 +5565,45 @@ fn build_mindmap_kernel_projection_result(
})
}
fn default_mindmap_theme_config() -> Value {
json!({
"lineColor": "#7aa2ff",
"lineStyle": "curve",
"rootLineKeepSameInCurve": true,
"rootLineStartPositionKeepSameInCurve": true,
"generalizationLineColor": "#ef6a5b",
"backgroundColor": "#f6f8fc",
"root": {
"fillColor": "#e25563",
"color": "#ffffff",
"fontWeight": "bold",
"borderColor": "transparent",
"borderWidth": 0,
"borderRadius": 8
},
"second": {
"fillColor": "#4f7df3",
"color": "#ffffff",
"borderColor": "transparent",
"borderWidth": 0,
"borderRadius": 8
},
"node": {
"fillColor": "transparent",
"color": "#315aa9",
"borderColor": "transparent",
"borderWidth": 0
},
"generalization": {
"fillColor": "#ffffff",
"color": "#ef6a5b",
"borderColor": "#ef6a5b",
"borderWidth": 1,
"borderRadius": 8
}
})
}
fn build_mindmap_adapter_projection_result(
data: &Value,
mindmap_id: &str,
@@ -5577,9 +5617,9 @@ fn build_mindmap_adapter_projection_result(
})?,
layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"])
.unwrap_or_else(|| json!("logicalStructure")),
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("classic")),
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")),
theme_config: read_mindmap_value_field(data, &["themeConfig", "theme_config"])
.unwrap_or_else(|| json!({})),
.unwrap_or_else(default_mindmap_theme_config),
view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})),
config: read_mindmap_value_field(data, &["config"]).unwrap_or_else(|| json!({})),
compat_payload: read_mindmap_value_field(data, &["compatPayload", "compat_payload"])
@@ -7075,6 +7115,33 @@ fn is_book_asset(file_name: &str, mime_type: &str) -> bool {
.any(|suffix| lowered_name.ends_with(suffix))
}
fn is_onlyoffice_asset(file_name: &str, mime_type: &str) -> bool {
let lowered_mime = mime_type.trim().to_ascii_lowercase();
let lowered_name = file_name.trim().to_ascii_lowercase();
lowered_mime.contains("officedocument")
|| lowered_mime.contains("msword")
|| lowered_mime.contains("ms-excel")
|| lowered_mime.contains("ms-powerpoint")
|| [
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp",
]
.iter()
.any(|suffix| lowered_name.ends_with(suffix))
}
fn is_code_asset(file_name: &str, mime_type: &str) -> bool {
let lowered_mime = mime_type.trim().to_ascii_lowercase();
let lowered_name = file_name.trim().to_ascii_lowercase();
lowered_mime.starts_with("text/")
&& [
".c", ".cc", ".cpp", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js",
".jsx", ".json", ".kt", ".lua", ".md", ".py", ".rs", ".sh", ".sql", ".toml",
".ts", ".tsx", ".xml", ".yaml", ".yml",
]
.iter()
.any(|suffix| lowered_name.ends_with(suffix))
}
fn classify_asset_kind(
asset_type: &str,
file_name: &str,
@@ -7111,7 +7178,17 @@ fn classify_asset_kind(
fn classify_asset_resource_kind(
asset_kind: &KernelProjectionAssetKind,
file_name: &str,
mime_type: &str,
) -> KernelProjectionResourceKind {
if matches!(asset_kind, KernelProjectionAssetKind::File) {
if is_onlyoffice_asset(file_name, mime_type) {
return KernelProjectionResourceKind::OnlyOffice;
}
if is_code_asset(file_name, mime_type) {
return KernelProjectionResourceKind::Code;
}
}
match asset_kind {
KernelProjectionAssetKind::Mindmap => KernelProjectionResourceKind::Mindmap,
KernelProjectionAssetKind::Table => KernelProjectionResourceKind::Table,
@@ -7403,6 +7480,7 @@ fn make_projection_resource_meta(
resource_kind: KernelProjectionResourceKind,
document_id: Option<String>,
asset_id: Option<String>,
block_id: Option<String>,
workspace_id: Option<String>,
asset_kind: Option<KernelProjectionAssetKind>,
icon_hint: &str,
@@ -7418,6 +7496,34 @@ fn make_projection_resource_meta(
) {
extra.insert("source".into(), source);
}
let object_kind = match (&resource_kind, &asset_kind, asset_id.as_ref()) {
(KernelProjectionResourceKind::Document, _, _) => KernelObjectKind::Page,
(KernelProjectionResourceKind::Index, _, _) => KernelObjectKind::Index,
(KernelProjectionResourceKind::Mindmap, _, _) => KernelObjectKind::Mindmap,
(KernelProjectionResourceKind::OnlyOffice, _, _) => KernelObjectKind::OnlyOffice,
(KernelProjectionResourceKind::Code, _, _) => KernelObjectKind::Code,
(_, Some(KernelProjectionAssetKind::Mindmap), _) => KernelObjectKind::Mindmap,
(_, _, Some(_)) => KernelObjectKind::Attachment,
_ => KernelObjectKind::Page,
};
let object_identity = Some(KernelObjectIdentity {
object_kind,
document_id: document_id.clone(),
block_id: block_id.clone(),
asset_id: asset_id.clone(),
});
let block_asset_relation =
match (document_id.clone(), block_id, asset_id.clone(), asset_kind.clone()) {
(Some(document_id), Some(block_id), Some(asset_id), Some(asset_kind)) => {
Some(KernelBlockAssetRelation {
document_id,
block_id,
asset_id,
asset_kind,
})
}
_ => None,
};
KernelProjectionResourceMeta {
resource_kind: Some(resource_kind),
document_id,
@@ -7425,6 +7531,8 @@ fn make_projection_resource_meta(
workspace_id,
asset_kind,
icon_hint: Some(icon_hint.into()),
object_identity,
block_asset_relation,
extra,
}
}
@@ -7433,6 +7541,7 @@ fn make_projection_resource_meta(
struct NormalizedFileTreeAsset {
id: String,
document_id: String,
block_id: Option<String>,
workspace_id: Option<String>,
title: String,
resource_kind: KernelProjectionResourceKind,
@@ -7451,7 +7560,7 @@ fn infer_file_tree_asset_shape(
) {
let asset_kind = classify_asset_kind(asset_type, file_name, mime_type);
(
classify_asset_resource_kind(&asset_kind),
classify_asset_resource_kind(&asset_kind, file_name, mime_type),
asset_kind.clone(),
classify_asset_icon_hint(&asset_kind),
)
@@ -7472,6 +7581,7 @@ fn normalize_file_tree_asset(value: &Value) -> Option<NormalizedFileTreeAsset> {
Some(NormalizedFileTreeAsset {
id,
document_id,
block_id: string_field(value, "block_id").or_else(|| string_field(value, "blockId")),
workspace_id: string_field(value, "workspace_id")
.or_else(|| string_field(value, "workspaceId")),
title: file_name,
@@ -7651,6 +7761,7 @@ fn build_file_tree_projection_result(
KernelProjectionResourceKind::Document,
Some(node.id.clone()),
None,
None,
workspace_id.clone(),
None,
"page",
@@ -7682,6 +7793,7 @@ fn build_file_tree_projection_result(
KernelProjectionResourceKind::Index,
Some(node.id.clone()),
None,
None,
workspace_id.clone(),
None,
"index",
@@ -7728,6 +7840,7 @@ fn build_file_tree_projection_result(
asset.resource_kind.clone(),
Some(asset.document_id.clone()),
Some(asset.id.clone()),
asset.block_id.clone(),
asset.workspace_id.clone(),
Some(KernelProjectionAssetKind::Mindmap),
"mindmap",
@@ -7768,6 +7881,7 @@ fn build_file_tree_projection_result(
child_asset.resource_kind.clone(),
Some(child_asset.document_id.clone()),
Some(child_asset.id.clone()),
child_asset.block_id.clone(),
child_asset.workspace_id.clone(),
Some(child_asset.asset_kind.clone()),
child_asset.icon_hint,
@@ -7808,6 +7922,7 @@ fn build_file_tree_projection_result(
asset.resource_kind.clone(),
Some(asset.document_id.clone()),
Some(asset.id.clone()),
asset.block_id.clone(),
asset.workspace_id.clone(),
Some(asset.asset_kind.clone()),
asset.icon_hint,
@@ -8141,6 +8256,7 @@ fn build_kernel_projection_result(
KernelProjectionResourceKind::Document,
Some(node.id.clone()),
None,
None,
node.workspace_id.clone(),
None,
"page",
@@ -9189,10 +9305,24 @@ fn execute_command(
source: workspace_source_value(&command_wire.source),
payload_json: request.payload_json,
args_json: json!({
"docId": payload.document_id,
"mindmapId": payload.mindmap_id,
"docId": payload.document_id.clone(),
"mindmapId": payload.mindmap_id.clone(),
"data": payload.data,
"createOnly": payload.create_only.unwrap_or(false),
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.put",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.put"),
"domainEventPlan": tree_domain_event_plan(
"tree.resource.mindmap.put",
tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.put",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
),
}),
}))
}
@@ -9229,11 +9359,25 @@ fn execute_command(
source: workspace_source_value(&command_wire.source),
payload_json: request.payload_json,
args_json: json!({
"documentId": payload.document_id,
"mindmapId": payload.mindmap_id,
"documentId": payload.document_id.clone(),
"mindmapId": payload.mindmap_id.clone(),
"commands": payload.commands,
"projectionRevision": payload.projection_revision,
"canonicalCommand": "mindmap.command.apply",
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.command.apply",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.updated"),
"domainEventPlan": tree_domain_event_plan(
"tree.resource.mindmap.updated",
tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.command.apply",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
),
}),
}))
}
@@ -9265,8 +9409,22 @@ fn execute_command(
source: workspace_source_value(&command_wire.source),
payload_json: request.payload_json,
args_json: json!({
"docId": payload.document_id,
"mindmapId": payload.mindmap_id,
"docId": payload.document_id.clone(),
"mindmapId": payload.mindmap_id.clone(),
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.delete",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.deleted"),
"domainEventPlan": tree_domain_event_plan(
"tree.resource.mindmap.deleted",
tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.delete",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
),
}),
}))
}
@@ -9299,8 +9457,22 @@ fn execute_command(
source: workspace_source_value(&command_wire.source),
payload_json: request.payload_json,
args_json: json!({
"docId": payload.document_id,
"mindmapId": payload.mindmap_id,
"docId": payload.document_id.clone(),
"mindmapId": payload.mindmap_id.clone(),
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.restore",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.restored"),
"domainEventPlan": tree_domain_event_plan(
"tree.resource.mindmap.restored",
tree_stream_delta_hint("resync_required", json!({
"reason": "mindmap.restore",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
),
}),
}))
}
@@ -10036,6 +10208,7 @@ where
fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
match raw_type {
"mindmap" => EditorBlockType::Mindmap,
"heading" => EditorBlockType::Heading,
"bullet_list_item" | "bullet_list" | "bullet-list" => EditorBlockType::BulletListItem,
"numbered_list_item" | "ordered_list" | "ordered-list" => EditorBlockType::NumberedListItem,
@@ -10058,11 +10231,9 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
let raw_type = read_trimmed_string_field(block, &["blockType", "type"])
.unwrap_or_else(|| "paragraph".into())
.to_lowercase();
let normalized_block_type = normalize_editor_block_type_for_save(&raw_type);
let mut props = BlockProps::default();
if matches!(
normalize_editor_block_type_for_save(&raw_type),
EditorBlockType::Heading
) {
if matches!(normalized_block_type, EditorBlockType::Heading) {
props.heading_level = block
.get("props")
.and_then(Value::as_object)
@@ -10076,20 +10247,14 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
.and_then(|map| map.get("collapsed"))
.and_then(Value::as_bool);
}
if matches!(
normalize_editor_block_type_for_save(&raw_type),
EditorBlockType::Todo
) {
if matches!(normalized_block_type, EditorBlockType::Todo) {
props.checked = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("checked"))
.and_then(Value::as_bool);
}
if matches!(
normalize_editor_block_type_for_save(&raw_type),
EditorBlockType::CodeBlock
) {
if matches!(normalized_block_type, EditorBlockType::CodeBlock) {
props.language = block
.get("props")
.and_then(Value::as_object)
@@ -10121,15 +10286,72 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
{
props.extra.insert("tiptapTocNode".into(), tiptap_toc);
}
if matches!(normalized_block_type, EditorBlockType::Mindmap) {
let props_map = block.get("props").and_then(Value::as_object);
let legacy_data = props_map.and_then(|map| map.get("data"));
let data_object = legacy_data.and_then(Value::as_object);
if let Some(mindmap_id) = props_map
.and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id")))
.and_then(Value::as_str)
.or_else(|| {
data_object.and_then(|map| {
map.get("mindmapId")
.or_else(|| map.get("mindmap_id"))
.or_else(|| map.get("id"))
.and_then(Value::as_str)
})
})
.map(str::trim)
.filter(|value| !value.is_empty())
{
props
.extra
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
} else {
props
.extra
.insert("mindmapId".into(), Value::String(block_id.clone()));
}
if let Some(root_node_id) = props_map
.and_then(|map| map.get("rootNodeId").or_else(|| map.get("root_node_id")))
.and_then(Value::as_str)
.or_else(|| {
data_object.and_then(|map| {
map.get("rootNodeId")
.or_else(|| map.get("root_node_id"))
.and_then(Value::as_str)
})
})
.map(str::trim)
.filter(|value| !value.is_empty())
{
props
.extra
.insert("rootNodeId".into(), Value::String(root_node_id.to_string()));
}
if let Some(projection_version) = props_map
.and_then(|map| map.get("projectionVersion"))
.and_then(Value::as_u64)
{
props.extra.insert(
"projectionVersion".into(),
Value::Number(projection_version.into()),
);
}
}
let text = read_trimmed_string_field(block, &["content"])
.filter(|value| !value.is_empty())
.unwrap_or_else(|| extract_inline_text(block));
EditorBlock {
block_id,
block_type: normalize_editor_block_type_for_save(&raw_type),
block_type: normalized_block_type.clone(),
props,
content_nodes: build_text_content_nodes(&text),
content_nodes: if matches!(normalized_block_type, EditorBlockType::Mindmap) {
vec![]
} else {
build_text_content_nodes(&text)
},
child_block_ids: vec![],
}
}
@@ -10156,6 +10378,7 @@ fn normalize_save_editor_document(
) -> Result<EditorBlockDocument, BridgeError> {
if let Some(editor_document) = payload.editor_document.clone() {
if let Ok(mut parsed) = serde_json::from_value::<EditorBlockDocument>(editor_document) {
hydrate_editor_document_props_from_raw(&mut parsed, payload.editor_document.as_ref());
if parsed.document_id.trim().is_empty() {
parsed.document_id = payload.document_id.clone();
}
@@ -10189,6 +10412,80 @@ fn normalize_save_editor_document(
))
}
fn hydrate_editor_document_props_from_raw(parsed: &mut EditorBlockDocument, raw: Option<&Value>) {
let Some(raw_blocks) = raw
.and_then(|value| value.get("blocks"))
.and_then(Value::as_array)
else {
return;
};
for (index, block) in parsed.blocks.iter_mut().enumerate() {
if !matches!(block.block_type, EditorBlockType::Mindmap) {
continue;
}
let Some(raw_block) = raw_blocks
.iter()
.find(|candidate| {
read_trimmed_string_field(candidate, &["blockId", "block_id"]).as_deref()
== Some(block.block_id.as_str())
})
.or_else(|| raw_blocks.get(index))
else {
continue;
};
hydrate_mindmap_block_props_from_raw(block, raw_block);
}
}
fn hydrate_mindmap_block_props_from_raw(block: &mut EditorBlock, raw_block: &Value) {
let props = raw_block.get("props").and_then(Value::as_object);
let legacy_data = props
.and_then(|map| map.get("data"))
.and_then(Value::as_object);
if let Some(mindmap_id) = props
.and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id")))
.and_then(Value::as_str)
.or_else(|| {
legacy_data.and_then(|map| {
map.get("mindmapId")
.or_else(|| map.get("mindmap_id"))
.or_else(|| map.get("id"))
.and_then(Value::as_str)
})
})
.map(str::trim)
.filter(|value| !value.is_empty())
{
block
.props
.extra
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
}
for (raw_key, canonical_key) in [("rootNodeId", "rootNodeId"), ("root_node_id", "rootNodeId")] {
if let Some(value) = props
.and_then(|map| map.get(raw_key))
.and_then(Value::as_str)
.or_else(|| legacy_data.and_then(|map| map.get(raw_key).and_then(Value::as_str)))
.map(str::trim)
.filter(|value| !value.is_empty())
{
block
.props
.extra
.insert(canonical_key.into(), Value::String(value.to_string()));
}
}
if let Some(projection_version) = props
.and_then(|map| map.get("projectionVersion"))
.and_then(Value::as_u64)
{
block.props.extra.insert(
"projectionVersion".into(),
Value::Number(projection_version.into()),
);
}
}
fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
let mut props = serde_json::Map::new();
match block.block_type {
@@ -10232,6 +10529,23 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
props.insert("tiptapTocNode".into(), tiptap_toc.clone());
}
}
EditorBlockType::Mindmap => {
if let Some(mindmap_id) = block.props.extra.get("mindmapId").and_then(Value::as_str) {
props.insert("mindmapId".into(), json!(mindmap_id));
}
if let Some(root_node_id) = block.props.extra.get("rootNodeId").and_then(Value::as_str)
{
props.insert("rootNodeId".into(), json!(root_node_id));
}
if let Some(projection_version) = block
.props
.extra
.get("projectionVersion")
.and_then(Value::as_u64)
{
props.insert("projectionVersion".into(), json!(projection_version));
}
}
_ => {}
}
if let Some(text_align) = block
@@ -10255,6 +10569,7 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
match block.block_type {
EditorBlockType::Paragraph => "paragraph",
EditorBlockType::Mindmap => "mindmap",
EditorBlockType::Heading => "heading",
EditorBlockType::BulletListItem => "bullet_list_item",
EditorBlockType::NumberedListItem => "numbered_list_item",
@@ -10302,6 +10617,8 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
"props": legacy_props_from_editor_block(block),
"content": if matches!(block.block_type, EditorBlockType::Divider) {
Value::Array(Vec::new())
} else if matches!(block.block_type, EditorBlockType::Mindmap) {
Value::String(String::new())
} else {
Value::String(legacy_text_from_editor_block(block))
},
@@ -11429,6 +11746,14 @@ mod tests {
"text": "新标题"
})
);
assert_eq!(
plan.args_json["domainEventPlan"]["eventType"],
json!("tree.resource.mindmap.updated")
);
assert_eq!(
plan.args_json["streamDeltaHint"]["kind"],
json!("resync_required")
);
}
RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => {
panic!("expected command plan")
@@ -11726,16 +12051,44 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "mindmaps:put");
assert_eq!(plan.args_json["docId"], json!("doc_1"));
assert_eq!(plan.args_json["mindmapId"], json!("mind_1"));
assert_eq!(
plan.args_json,
plan.args_json["data"],
json!({
"docId": "doc_1",
"mindmapId": "mind_1",
"data": {
"data": {"text": "中心主题"},
"children": [],
},
"createOnly": true,
"data": {"text": "中心主题"},
"children": [],
})
);
assert_eq!(plan.args_json["createOnly"], json!(true));
assert_eq!(
plan.args_json["streamDeltaHint"],
json!({
"family": "tree",
"kind": "resync_required",
"args": {
"reason": "mindmap.put",
"documentId": "doc_1",
"blockId": "mind_1",
}
})
);
assert_eq!(
plan.args_json["domainEventPlan"],
json!({
"family": "tree",
"schema": "mnote.tree.domain_event",
"schemaVersion": 1,
"eventType": "tree.resource.mindmap.put",
"streamDeltaHint": {
"family": "tree",
"kind": "resync_required",
"args": {
"reason": "mindmap.put",
"documentId": "doc_1",
"blockId": "mind_1",
}
}
})
);
}
@@ -12317,6 +12670,178 @@ mod tests {
);
}
#[test]
fn documents_save_command_plan_preserves_mindmap_placeholder() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_mindmap".into(),
idempotency_key: Some("idem_save_mindmap".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(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
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": 6,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "paragraph",
"attrs": {
"blockId": "mind_1",
"mnoteBlockType": "mindmap",
"mindmapId": "mind_1",
"rootNodeId": "root",
"projectionVersion": 1
}
}]
},
"conflictDetectionKey": "doc_1:6"
}),
preflight_data: None,
reason: Some("保存导图占位".into()),
refs: vec!["phase6-mindmap".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save mindmap 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!("mindmap"))
);
assert_eq!(
plan.args_json.pointer("/content/0/type"),
Some(&json!("mindmap"))
);
assert_eq!(
plan.args_json.pointer("/content/0/id"),
Some(&json!("mind_1"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/mindmapId"),
Some(&json!("mind_1"))
);
assert_eq!(plan.args_json.pointer("/content/0/props/data"), None);
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!(""))
);
}
#[test]
fn documents_save_command_plan_preserves_mindmap_props_from_editor_document() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "page.body.save".into(),
command_id: "cmd_save_mindmap_editor_document".into(),
idempotency_key: Some("idem_save_mindmap_editor_document".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(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
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": 7,
"editorDocument": {
"documentId": "doc_1",
"rootBlockIds": ["block-1"],
"blocks": [{
"blockId": "block-1",
"blockType": "mindmap",
"props": {
"data": null,
"mindmapId": "mind_1",
"rootNodeId": "root",
"projectionVersion": 1
},
"contentNodes": [],
"childBlockIds": []
}]
},
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "paragraph",
"attrs": {
"blockId": "block-1",
"mnoteBlockType": "mindmap",
"mindmapId": "mind_1",
"rootNodeId": "root",
"projectionVersion": 1
}
}]
},
"conflictDetectionKey": "doc_1:7"
}),
preflight_data: None,
reason: Some("保存 editorDocument 导图占位".into()),
refs: vec!["task169-mindmap-realtime-smoke".into()],
dry_run: false,
validate_only: false,
},
})
.expect("page.body.save mindmap editorDocument plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/content/0/props/mindmapId"),
Some(&json!("mind_1"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/rootNodeId"),
Some(&json!("root"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/projectionVersion"),
Some(&json!(1))
);
assert_eq!(plan.args_json.pointer("/content/0/props/data"), None);
}
#[test]
fn documents_save_command_plan_preserves_tiptap_image() {
let plan = execute_runtime_input(RuntimeInput::Command {
@@ -16219,6 +16744,7 @@ mod tests {
"id": "mind_1",
"workspace_id": "ws_1",
"document_id": "page_root",
"block_id": "block_mind_1",
"asset_type": "mindmap",
"file_name": "roadmap.json",
"mime_type": "application/json",
@@ -16293,6 +16819,15 @@ mod tests {
item_by_row_id["index:page_root"]["resourceMeta"]["resourceKind"],
json!("index")
);
assert_eq!(
item_by_row_id["index:page_root"]["resourceMeta"]["objectIdentity"],
json!({
"objectKind": "index",
"documentId": "page_root",
"blockId": null,
"assetId": null
})
);
assert_eq!(
item_by_row_id["index:page_root"]["resourceMeta"]["extra"]["source"],
json!({
@@ -16333,6 +16868,24 @@ mod tests {
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["assetKind"],
json!("mindmap")
);
assert_eq!(
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["objectIdentity"],
json!({
"objectKind": "mindmap",
"documentId": "page_root",
"blockId": "block_mind_1",
"assetId": "mind_1"
})
);
assert_eq!(
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["blockAssetRelation"],
json!({
"documentId": "page_root",
"blockId": "block_mind_1",
"assetId": "mind_1",
"assetKind": "mindmap"
})
);
assert_eq!(
item_by_row_id["asset:asset_ref_1"]["parentNodeId"],
json!("asset-folder:mind_1")
@@ -16566,6 +17119,108 @@ mod tests {
assert!(!row_ids.contains(&"asset:asset_rust_child"));
}
#[test]
fn kernel_file_tree_projection_separates_attachment_object_identities() {
let result = execute_runtime_query(RuntimeInput::Query {
context: demo_context(),
query: RuntimeQueryEnvelopeWire {
name: "kernel.project_view".into(),
payload: json!({
"projection": "file_tree",
"workspaceId": "ws_1",
"rootNodeId": "page_root",
"depth": 2,
"includeEdges": true,
"nodeTypes": ["page"],
}),
},
data: Some(json!({
"documents": [
{
"id": "page_root",
"workspace_id": "ws_1",
"title": "根页面",
"parent_id": null,
"sort_order": 0,
"is_starred": true,
"is_template": false,
"created_at": "2026-04-16T00:00:00Z",
"updated_at": "2026-04-16T00:00:00Z"
}
],
"media_assets": [
{
"id": "office_1",
"workspace_id": "ws_1",
"document_id": "page_root",
"block_id": "block_office_1",
"asset_type": "file",
"file_name": "contract.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
},
{
"id": "code_1",
"workspace_id": "ws_1",
"document_id": "page_root",
"block_id": "block_code_1",
"asset_type": "file",
"file_name": "main.rs",
"mime_type": "text/rust"
},
{
"id": "image_1",
"workspace_id": "ws_1",
"document_id": "page_root",
"block_id": "block_image_1",
"asset_type": "file",
"file_name": "cover.png",
"mime_type": "image/png"
}
],
"mindmap_assets": [],
"table_assets": [],
"mindmap_asset_children": {}
})),
})
.expect("file_tree object identity projection should build");
let items = result["items"].as_array().expect("items should be array");
let item_by_row_id = items
.iter()
.filter_map(|item| {
item.get("rowId")
.and_then(Value::as_str)
.map(|row_id| (row_id.to_string(), item))
})
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
item_by_row_id["asset:office_1"]["resourceMeta"]["resourceKind"],
json!("only_office")
);
assert_eq!(
item_by_row_id["asset:office_1"]["resourceMeta"]["objectIdentity"]["objectKind"],
json!("only_office")
);
assert_eq!(
item_by_row_id["asset:code_1"]["resourceMeta"]["resourceKind"],
json!("code")
);
assert_eq!(
item_by_row_id["asset:code_1"]["resourceMeta"]["objectIdentity"]["objectKind"],
json!("code")
);
assert_eq!(
item_by_row_id["asset:image_1"]["resourceMeta"]["objectIdentity"],
json!({
"objectKind": "attachment",
"documentId": "page_root",
"blockId": "block_image_1",
"assetId": "image_1"
})
);
}
#[test]
fn kernel_file_tree_projection_query_matches_index_resource() {
let result = execute_runtime_query(RuntimeInput::Query {