feat: complete tree shell cutover and regression coverage
This commit is contained in:
@@ -12,9 +12,12 @@ use core_protocol::{
|
||||
KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType,
|
||||
KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult,
|
||||
KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode,
|
||||
KernelNodeMetadata, KernelNodeType, KernelProjectionFilter, KernelProjectionItem,
|
||||
KernelProjectionKind, KernelProjectionRequest, KernelProjectionResult, KernelRefsPayload,
|
||||
KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
||||
KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem,
|
||||
KernelProjectionKind, KernelProjectionRequest, KernelProjectionResourceKind,
|
||||
KernelProjectionResourceMeta, KernelProjectionResult, KernelProjectionRowKind,
|
||||
KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph,
|
||||
KernelUpdateNode,
|
||||
ListBridgeWorkspaceOverview, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
|
||||
MindmapTreeNode, MoveBlock, PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments,
|
||||
SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation,
|
||||
@@ -3340,6 +3343,137 @@ fn bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||
record_field(value, key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn integer_field(value: &Value, key: &str) -> Option<i64> {
|
||||
record_field(value, key)
|
||||
.and_then(|field| field.as_i64().or_else(|| field.as_f64().map(|number| number as i64)))
|
||||
}
|
||||
|
||||
fn is_pdf_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("pdf") || lowered_name.ends_with(".pdf")
|
||||
}
|
||||
|
||||
fn is_book_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("epub")
|
||||
|| lowered_mime.contains("ebook")
|
||||
|| [".epub", ".mobi", ".azw3", ".azw", ".fb2"]
|
||||
.iter()
|
||||
.any(|suffix| lowered_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
fn classify_asset_kind(asset_type: &str, file_name: &str, mime_type: &str) -> KernelProjectionAssetKind {
|
||||
let lowered_type = asset_type.trim().to_ascii_lowercase();
|
||||
let lowered_mime = mime_type.trim().to_ascii_lowercase();
|
||||
if lowered_type == "mindmap" {
|
||||
return KernelProjectionAssetKind::Mindmap;
|
||||
}
|
||||
if lowered_type == "luckysheet" {
|
||||
return KernelProjectionAssetKind::Table;
|
||||
}
|
||||
if is_pdf_asset(file_name, mime_type) {
|
||||
return KernelProjectionAssetKind::Pdf;
|
||||
}
|
||||
if is_book_asset(file_name, mime_type) {
|
||||
return KernelProjectionAssetKind::Book;
|
||||
}
|
||||
if lowered_mime.starts_with("image/") {
|
||||
return KernelProjectionAssetKind::Image;
|
||||
}
|
||||
if lowered_mime.starts_with("video/") {
|
||||
return KernelProjectionAssetKind::Video;
|
||||
}
|
||||
if lowered_mime.starts_with("audio/") {
|
||||
return KernelProjectionAssetKind::Audio;
|
||||
}
|
||||
if lowered_type == "file" || lowered_type.is_empty() {
|
||||
return KernelProjectionAssetKind::File;
|
||||
}
|
||||
KernelProjectionAssetKind::Unknown
|
||||
}
|
||||
|
||||
fn classify_asset_resource_kind(asset_kind: &KernelProjectionAssetKind) -> KernelProjectionResourceKind {
|
||||
match asset_kind {
|
||||
KernelProjectionAssetKind::Mindmap => KernelProjectionResourceKind::Mindmap,
|
||||
KernelProjectionAssetKind::Table => KernelProjectionResourceKind::Table,
|
||||
KernelProjectionAssetKind::Book => KernelProjectionResourceKind::Book,
|
||||
KernelProjectionAssetKind::Pdf => KernelProjectionResourceKind::Pdf,
|
||||
_ => KernelProjectionResourceKind::Asset,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_asset_node_type(asset_kind: &KernelProjectionAssetKind) -> KernelNodeType {
|
||||
match asset_kind {
|
||||
KernelProjectionAssetKind::Mindmap => KernelNodeType::Mindmap,
|
||||
KernelProjectionAssetKind::Table => KernelNodeType::Table,
|
||||
KernelProjectionAssetKind::Book => KernelNodeType::Book,
|
||||
KernelProjectionAssetKind::Pdf => KernelNodeType::Pdf,
|
||||
_ => KernelNodeType::Asset,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_asset_icon_hint(asset_kind: &KernelProjectionAssetKind) -> &'static str {
|
||||
match asset_kind {
|
||||
KernelProjectionAssetKind::Mindmap => "mindmap",
|
||||
KernelProjectionAssetKind::Table => "table",
|
||||
KernelProjectionAssetKind::Book => "book",
|
||||
KernelProjectionAssetKind::Pdf => "pdf",
|
||||
KernelProjectionAssetKind::Image => "image",
|
||||
KernelProjectionAssetKind::Video => "video",
|
||||
KernelProjectionAssetKind::Audio => "audio",
|
||||
_ => "file",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_projection_row_id(
|
||||
projection: &KernelProjectionKind,
|
||||
row_kind: &KernelProjectionRowKind,
|
||||
node_id: &str,
|
||||
) -> String {
|
||||
match projection {
|
||||
KernelProjectionKind::FileTree => match row_kind {
|
||||
KernelProjectionRowKind::Document => format!("doc:{node_id}"),
|
||||
KernelProjectionRowKind::Index => format!("index:{node_id}"),
|
||||
KernelProjectionRowKind::Asset => format!("asset:{node_id}"),
|
||||
KernelProjectionRowKind::AssetFolder => format!("asset-folder:{node_id}"),
|
||||
},
|
||||
_ => format!("page:{node_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_document_projection_capabilities(child_count: u32) -> Vec<KernelProjectionCapability> {
|
||||
let mut capabilities = vec![
|
||||
KernelProjectionCapability::Open,
|
||||
KernelProjectionCapability::Drag,
|
||||
KernelProjectionCapability::Drop,
|
||||
KernelProjectionCapability::Select,
|
||||
KernelProjectionCapability::CreateChild,
|
||||
KernelProjectionCapability::Rename,
|
||||
KernelProjectionCapability::Archive,
|
||||
KernelProjectionCapability::Restore,
|
||||
KernelProjectionCapability::ContextMenu,
|
||||
KernelProjectionCapability::Reorder,
|
||||
];
|
||||
if child_count > 0 {
|
||||
capabilities.insert(0, KernelProjectionCapability::Expand);
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
fn build_asset_folder_projection_capabilities(child_count: u32) -> Vec<KernelProjectionCapability> {
|
||||
let mut capabilities = vec![
|
||||
KernelProjectionCapability::OpenAsset,
|
||||
KernelProjectionCapability::Select,
|
||||
KernelProjectionCapability::ContextMenu,
|
||||
];
|
||||
if child_count > 0 {
|
||||
capabilities.insert(0, KernelProjectionCapability::Expand);
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
fn normalize_kernel_node_from_record(
|
||||
value: &Value,
|
||||
workspace_id: Option<&str>,
|
||||
@@ -3375,9 +3509,8 @@ fn normalize_kernel_node_from_record(
|
||||
if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) {
|
||||
extra.insert("isStarred".into(), json!(is_starred));
|
||||
}
|
||||
if let Some(sort_order) = record_field(value, "sort_order")
|
||||
.and_then(|field| field.as_i64().or_else(|| field.as_f64().map(|number| number as i64)))
|
||||
.or_else(|| record_field(value, "sortOrder").and_then(Value::as_i64))
|
||||
if let Some(sort_order) = integer_field(value, "sort_order")
|
||||
.or_else(|| integer_field(value, "sortOrder"))
|
||||
{
|
||||
extra.insert("sortOrder".into(), json!(sort_order));
|
||||
}
|
||||
@@ -3446,6 +3579,484 @@ fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec<KernelEdge> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kernel_node_sort_order(node: &KernelNode) -> i64 {
|
||||
node.metadata
|
||||
.extra
|
||||
.get("sortOrder")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn file_tree_leaf_capabilities(open_asset: bool) -> Vec<KernelProjectionCapability> {
|
||||
let mut capabilities = vec![
|
||||
KernelProjectionCapability::Select,
|
||||
KernelProjectionCapability::ContextMenu,
|
||||
];
|
||||
if open_asset {
|
||||
capabilities.insert(0, KernelProjectionCapability::OpenAsset);
|
||||
} else {
|
||||
capabilities.insert(0, KernelProjectionCapability::Open);
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
fn make_projection_edge(
|
||||
parent_node_id: &str,
|
||||
child_node_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
) -> KernelEdge {
|
||||
KernelEdge {
|
||||
id: format!("edge_parent_of_{}_{}", parent_node_id, child_node_id),
|
||||
edge_type: KernelEdgeType::ParentOf,
|
||||
workspace_id: workspace_id.map(ToOwned::to_owned),
|
||||
from_node_id: parent_node_id.to_string(),
|
||||
to_node_id: child_node_id.to_string(),
|
||||
metadata: BTreeMap::new(),
|
||||
audit: KernelAuditStamp::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_projection_resource_meta(
|
||||
resource_kind: KernelProjectionResourceKind,
|
||||
document_id: Option<String>,
|
||||
asset_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
asset_kind: Option<KernelProjectionAssetKind>,
|
||||
icon_hint: &str,
|
||||
row_kind: &str,
|
||||
) -> KernelProjectionResourceMeta {
|
||||
let mut extra = BTreeMap::new();
|
||||
extra.insert("rowKind".into(), json!(row_kind));
|
||||
KernelProjectionResourceMeta {
|
||||
resource_kind: Some(resource_kind),
|
||||
document_id,
|
||||
asset_id,
|
||||
workspace_id,
|
||||
asset_kind,
|
||||
icon_hint: Some(icon_hint.into()),
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NormalizedFileTreeAsset {
|
||||
id: String,
|
||||
document_id: String,
|
||||
workspace_id: Option<String>,
|
||||
title: String,
|
||||
resource_kind: KernelProjectionResourceKind,
|
||||
asset_kind: KernelProjectionAssetKind,
|
||||
icon_hint: &'static str,
|
||||
}
|
||||
|
||||
fn infer_file_tree_asset_shape(
|
||||
asset_type: &str,
|
||||
file_name: &str,
|
||||
mime_type: &str,
|
||||
) -> (KernelProjectionResourceKind, KernelProjectionAssetKind, &'static str) {
|
||||
let asset_kind = classify_asset_kind(asset_type, file_name, mime_type);
|
||||
(
|
||||
classify_asset_resource_kind(&asset_kind),
|
||||
asset_kind.clone(),
|
||||
classify_asset_icon_hint(&asset_kind),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_file_tree_asset(value: &Value) -> Option<NormalizedFileTreeAsset> {
|
||||
let id = string_field(value, "id")?;
|
||||
let document_id = string_field(value, "document_id").or_else(|| string_field(value, "documentId"))?;
|
||||
let file_name = string_field(value, "file_name")
|
||||
.or_else(|| string_field(value, "title"))
|
||||
.unwrap_or_else(|| "附件".into());
|
||||
let mime_type = string_field(value, "mime_type").unwrap_or_default();
|
||||
let asset_type = string_field(value, "asset_type").unwrap_or_else(|| "file".into());
|
||||
let (resource_kind, asset_kind, icon_hint) =
|
||||
infer_file_tree_asset_shape(&asset_type, &file_name, &mime_type);
|
||||
|
||||
Some(NormalizedFileTreeAsset {
|
||||
id,
|
||||
document_id,
|
||||
workspace_id: string_field(value, "workspace_id").or_else(|| string_field(value, "workspaceId")),
|
||||
title: file_name,
|
||||
resource_kind,
|
||||
asset_kind,
|
||||
icon_hint,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_file_tree_assets(
|
||||
data: &Value,
|
||||
) -> (
|
||||
BTreeMap<String, Vec<NormalizedFileTreeAsset>>,
|
||||
BTreeMap<String, NormalizedFileTreeAsset>,
|
||||
BTreeMap<String, Vec<String>>,
|
||||
) {
|
||||
let mut assets_by_doc = BTreeMap::<String, Vec<NormalizedFileTreeAsset>>::new();
|
||||
let mut asset_by_id = BTreeMap::<String, NormalizedFileTreeAsset>::new();
|
||||
let mut seen = BTreeMap::<String, bool>::new();
|
||||
|
||||
for key in ["media_assets", "mindmap_assets", "table_assets"] {
|
||||
for value in data
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
{
|
||||
let Some(asset) = normalize_file_tree_asset(&value) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(asset.id.clone(), true).is_some() {
|
||||
continue;
|
||||
}
|
||||
assets_by_doc
|
||||
.entry(asset.document_id.clone())
|
||||
.or_default()
|
||||
.push(asset.clone());
|
||||
asset_by_id.insert(asset.id.clone(), asset);
|
||||
}
|
||||
}
|
||||
|
||||
let child_map = data
|
||||
.get("mindmap_asset_children")
|
||||
.and_then(Value::as_object)
|
||||
.map(|object| {
|
||||
object
|
||||
.iter()
|
||||
.map(|(asset_id, children)| {
|
||||
let child_ids = children
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
(asset_id.clone(), child_ids)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
(assets_by_doc, asset_by_id, child_map)
|
||||
}
|
||||
|
||||
fn build_file_tree_projection_result(
|
||||
subtree: &KernelSubtreeResult,
|
||||
_workspace_id: Option<&str>,
|
||||
root_node_id: Option<&str>,
|
||||
data: &Value,
|
||||
) -> KernelProjectionResult {
|
||||
let (assets_by_doc, asset_by_id, child_assets_by_parent) = build_file_tree_assets(data);
|
||||
let page_ids = subtree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| (node.id.clone(), node.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut child_pages = BTreeMap::<String, Vec<String>>::new();
|
||||
for node in &subtree.nodes {
|
||||
if let Some(parent_id) = node.parent_id.as_ref() {
|
||||
child_pages
|
||||
.entry(parent_id.clone())
|
||||
.or_default()
|
||||
.push(node.id.clone());
|
||||
}
|
||||
}
|
||||
for ids in child_pages.values_mut() {
|
||||
ids.sort_by_key(|node_id| {
|
||||
page_ids
|
||||
.get(node_id)
|
||||
.map(kernel_node_sort_order)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
}
|
||||
|
||||
let mut roots = if let Some(root_node_id) = root_node_id {
|
||||
vec![root_node_id.to_string()]
|
||||
} else {
|
||||
subtree
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| {
|
||||
node.parent_id
|
||||
.as_ref()
|
||||
.map(|parent_id| !page_ids.contains_key(parent_id))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(|node| node.id.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
roots.sort_by_key(|node_id| {
|
||||
page_ids
|
||||
.get(node_id)
|
||||
.map(kernel_node_sort_order)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let mut items = Vec::<KernelProjectionItem>::new();
|
||||
let mut edges = subtree.edges.clone();
|
||||
|
||||
fn walk_page(
|
||||
page_id: &str,
|
||||
depth: u32,
|
||||
page_ids: &BTreeMap<String, KernelNode>,
|
||||
child_pages: &BTreeMap<String, Vec<String>>,
|
||||
assets_by_doc: &BTreeMap<String, Vec<NormalizedFileTreeAsset>>,
|
||||
asset_by_id: &BTreeMap<String, NormalizedFileTreeAsset>,
|
||||
child_assets_by_parent: &BTreeMap<String, Vec<String>>,
|
||||
items: &mut Vec<KernelProjectionItem>,
|
||||
edges: &mut Vec<KernelEdge>,
|
||||
) {
|
||||
let Some(node) = page_ids.get(page_id) else {
|
||||
return;
|
||||
};
|
||||
let direct_child_pages = child_pages.get(page_id).cloned().unwrap_or_default();
|
||||
let doc_assets = assets_by_doc.get(page_id).cloned().unwrap_or_default();
|
||||
let mut nested_child_asset_ids = BTreeMap::<String, bool>::new();
|
||||
for asset in &doc_assets {
|
||||
if let Some(child_ids) = child_assets_by_parent.get(&asset.id) {
|
||||
for child_id in child_ids {
|
||||
nested_child_asset_ids.insert(child_id.clone(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let direct_asset_count = doc_assets
|
||||
.iter()
|
||||
.filter(|asset| !nested_child_asset_ids.contains_key(&asset.id))
|
||||
.count() as u32;
|
||||
let doc_child_count = 1 + direct_asset_count + direct_child_pages.len() as u32;
|
||||
let parent_node_id = if depth == 0 { None } else { node.parent_id.clone() };
|
||||
let workspace_id = node.workspace_id.clone();
|
||||
items.push(KernelProjectionItem {
|
||||
row_id: build_projection_row_id(
|
||||
&KernelProjectionKind::FileTree,
|
||||
&KernelProjectionRowKind::Document,
|
||||
&node.id,
|
||||
),
|
||||
row_kind: KernelProjectionRowKind::Document,
|
||||
node_id: node.id.clone(),
|
||||
parent_node_id,
|
||||
node_type: KernelNodeType::Page,
|
||||
projection_kind: KernelProjectionKind::FileTree,
|
||||
title: node.metadata.title.clone(),
|
||||
depth,
|
||||
position: Some(kernel_node_sort_order(node)),
|
||||
child_count: doc_child_count,
|
||||
expandable: doc_child_count > 0,
|
||||
expanded_by_default: true,
|
||||
capabilities: build_document_projection_capabilities(doc_child_count),
|
||||
resource_meta: Some(make_projection_resource_meta(
|
||||
KernelProjectionResourceKind::Document,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
workspace_id.clone(),
|
||||
None,
|
||||
"page",
|
||||
"document",
|
||||
)),
|
||||
icon_hint: Some("page".into()),
|
||||
});
|
||||
|
||||
let index_node_id = format!("index:{}", node.id);
|
||||
items.push(KernelProjectionItem {
|
||||
row_id: build_projection_row_id(
|
||||
&KernelProjectionKind::FileTree,
|
||||
&KernelProjectionRowKind::Index,
|
||||
&node.id,
|
||||
),
|
||||
row_kind: KernelProjectionRowKind::Index,
|
||||
node_id: index_node_id.clone(),
|
||||
parent_node_id: Some(node.id.clone()),
|
||||
node_type: KernelNodeType::IndexNode,
|
||||
projection_kind: KernelProjectionKind::FileTree,
|
||||
title: Some("index.md".into()),
|
||||
depth: depth + 1,
|
||||
position: Some(0),
|
||||
child_count: 0,
|
||||
expandable: false,
|
||||
expanded_by_default: false,
|
||||
capabilities: file_tree_leaf_capabilities(false),
|
||||
resource_meta: Some(make_projection_resource_meta(
|
||||
KernelProjectionResourceKind::Index,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
workspace_id.clone(),
|
||||
None,
|
||||
"index",
|
||||
"index",
|
||||
)),
|
||||
icon_hint: Some("index".into()),
|
||||
});
|
||||
edges.push(make_projection_edge(
|
||||
&node.id,
|
||||
&index_node_id,
|
||||
workspace_id.as_deref(),
|
||||
));
|
||||
|
||||
let mut asset_position = 1i64;
|
||||
for asset in doc_assets {
|
||||
if nested_child_asset_ids.contains_key(&asset.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(child_ids) = child_assets_by_parent.get(&asset.id).filter(|ids| !ids.is_empty()) {
|
||||
let folder_node_id = format!("asset-folder:{}", asset.id);
|
||||
items.push(KernelProjectionItem {
|
||||
row_id: build_projection_row_id(
|
||||
&KernelProjectionKind::FileTree,
|
||||
&KernelProjectionRowKind::AssetFolder,
|
||||
&asset.id,
|
||||
),
|
||||
row_kind: KernelProjectionRowKind::AssetFolder,
|
||||
node_id: folder_node_id.clone(),
|
||||
parent_node_id: Some(node.id.clone()),
|
||||
node_type: KernelNodeType::Mindmap,
|
||||
projection_kind: KernelProjectionKind::FileTree,
|
||||
title: Some(asset.title.trim_end_matches(".json").to_string()),
|
||||
depth: depth + 1,
|
||||
position: Some(asset_position),
|
||||
child_count: child_ids.len() as u32,
|
||||
expandable: true,
|
||||
expanded_by_default: false,
|
||||
capabilities: build_asset_folder_projection_capabilities(child_ids.len() as u32),
|
||||
resource_meta: Some(make_projection_resource_meta(
|
||||
asset.resource_kind.clone(),
|
||||
Some(asset.document_id.clone()),
|
||||
Some(asset.id.clone()),
|
||||
asset.workspace_id.clone(),
|
||||
Some(KernelProjectionAssetKind::Mindmap),
|
||||
"mindmap",
|
||||
"asset_folder",
|
||||
)),
|
||||
icon_hint: Some("mindmap".into()),
|
||||
});
|
||||
edges.push(make_projection_edge(
|
||||
&node.id,
|
||||
&folder_node_id,
|
||||
workspace_id.as_deref(),
|
||||
));
|
||||
|
||||
for (child_index, child_id) in child_ids.iter().enumerate() {
|
||||
let Some(child_asset) = asset_by_id.get(child_id) else {
|
||||
continue;
|
||||
};
|
||||
let child_node_id = format!("asset:{}", child_asset.id);
|
||||
items.push(KernelProjectionItem {
|
||||
row_id: build_projection_row_id(
|
||||
&KernelProjectionKind::FileTree,
|
||||
&KernelProjectionRowKind::Asset,
|
||||
&child_asset.id,
|
||||
),
|
||||
row_kind: KernelProjectionRowKind::Asset,
|
||||
node_id: child_node_id.clone(),
|
||||
parent_node_id: Some(folder_node_id.clone()),
|
||||
node_type: KernelNodeType::Asset,
|
||||
projection_kind: KernelProjectionKind::FileTree,
|
||||
title: Some(child_asset.title.clone()),
|
||||
depth: depth + 2,
|
||||
position: Some(child_index as i64),
|
||||
child_count: 0,
|
||||
expandable: false,
|
||||
expanded_by_default: false,
|
||||
capabilities: file_tree_leaf_capabilities(true),
|
||||
resource_meta: Some(make_projection_resource_meta(
|
||||
child_asset.resource_kind.clone(),
|
||||
Some(child_asset.document_id.clone()),
|
||||
Some(child_asset.id.clone()),
|
||||
child_asset.workspace_id.clone(),
|
||||
Some(child_asset.asset_kind.clone()),
|
||||
child_asset.icon_hint,
|
||||
"asset",
|
||||
)),
|
||||
icon_hint: Some(child_asset.icon_hint.into()),
|
||||
});
|
||||
edges.push(make_projection_edge(
|
||||
&folder_node_id,
|
||||
&child_node_id,
|
||||
workspace_id.as_deref(),
|
||||
));
|
||||
}
|
||||
asset_position += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let asset_node_id = format!("asset:{}", asset.id);
|
||||
items.push(KernelProjectionItem {
|
||||
row_id: build_projection_row_id(
|
||||
&KernelProjectionKind::FileTree,
|
||||
&KernelProjectionRowKind::Asset,
|
||||
&asset.id,
|
||||
),
|
||||
row_kind: KernelProjectionRowKind::Asset,
|
||||
node_id: asset_node_id.clone(),
|
||||
parent_node_id: Some(node.id.clone()),
|
||||
node_type: classify_asset_node_type(&asset.asset_kind),
|
||||
projection_kind: KernelProjectionKind::FileTree,
|
||||
title: Some(asset.title.clone()),
|
||||
depth: depth + 1,
|
||||
position: Some(asset_position),
|
||||
child_count: 0,
|
||||
expandable: false,
|
||||
expanded_by_default: false,
|
||||
capabilities: file_tree_leaf_capabilities(true),
|
||||
resource_meta: Some(make_projection_resource_meta(
|
||||
asset.resource_kind.clone(),
|
||||
Some(asset.document_id.clone()),
|
||||
Some(asset.id.clone()),
|
||||
asset.workspace_id.clone(),
|
||||
Some(asset.asset_kind.clone()),
|
||||
asset.icon_hint,
|
||||
"asset",
|
||||
)),
|
||||
icon_hint: Some(asset.icon_hint.into()),
|
||||
});
|
||||
edges.push(make_projection_edge(
|
||||
&node.id,
|
||||
&asset_node_id,
|
||||
workspace_id.as_deref(),
|
||||
));
|
||||
asset_position += 1;
|
||||
}
|
||||
|
||||
for child_page_id in direct_child_pages {
|
||||
walk_page(
|
||||
&child_page_id,
|
||||
depth + 1,
|
||||
page_ids,
|
||||
child_pages,
|
||||
assets_by_doc,
|
||||
asset_by_id,
|
||||
child_assets_by_parent,
|
||||
items,
|
||||
edges,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for root_id in roots {
|
||||
walk_page(
|
||||
&root_id,
|
||||
0,
|
||||
&page_ids,
|
||||
&child_pages,
|
||||
&assets_by_doc,
|
||||
&asset_by_id,
|
||||
&child_assets_by_parent,
|
||||
&mut items,
|
||||
&mut edges,
|
||||
);
|
||||
}
|
||||
|
||||
KernelProjectionResult {
|
||||
projection_id: format!(
|
||||
"kernel_projection:file_tree:{}",
|
||||
root_node_id.unwrap_or("root")
|
||||
),
|
||||
projection: KernelProjectionKind::FileTree,
|
||||
root_node_id: root_node_id.map(ToOwned::to_owned),
|
||||
items,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_kernel_subtree_result(
|
||||
data: &Value,
|
||||
root_node_id: &str,
|
||||
@@ -3569,30 +4180,60 @@ fn build_kernel_projection_result(
|
||||
edges,
|
||||
}
|
||||
};
|
||||
if projection == KernelProjectionKind::FileTree {
|
||||
return Ok(build_file_tree_projection_result(
|
||||
&subtree,
|
||||
workspace_id,
|
||||
root_node_id,
|
||||
data,
|
||||
));
|
||||
}
|
||||
let items = subtree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| KernelProjectionItem {
|
||||
node_id: node.id.clone(),
|
||||
parent_node_id: node.parent_id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
title: node.metadata.title.clone(),
|
||||
depth: node
|
||||
.subtree
|
||||
.as_ref()
|
||||
.and_then(|subtree| subtree.depth)
|
||||
.unwrap_or(0),
|
||||
position: node
|
||||
.metadata
|
||||
.extra
|
||||
.get("sortOrder")
|
||||
.and_then(Value::as_i64),
|
||||
child_count: subtree
|
||||
.map(|node| {
|
||||
let child_count = subtree
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|edge| edge.from_node_id == node.id && edge.edge_type == KernelEdgeType::ParentOf)
|
||||
.count() as u32,
|
||||
expanded_by_default: true,
|
||||
.count() as u32;
|
||||
KernelProjectionItem {
|
||||
row_id: build_projection_row_id(
|
||||
&projection,
|
||||
&KernelProjectionRowKind::Document,
|
||||
&node.id,
|
||||
),
|
||||
row_kind: KernelProjectionRowKind::Document,
|
||||
node_id: node.id.clone(),
|
||||
parent_node_id: node.parent_id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
projection_kind: projection.clone(),
|
||||
title: node.metadata.title.clone(),
|
||||
depth: node
|
||||
.subtree
|
||||
.as_ref()
|
||||
.and_then(|subtree| subtree.depth)
|
||||
.unwrap_or(0),
|
||||
position: node
|
||||
.metadata
|
||||
.extra
|
||||
.get("sortOrder")
|
||||
.and_then(Value::as_i64),
|
||||
child_count,
|
||||
expandable: child_count > 0,
|
||||
expanded_by_default: true,
|
||||
capabilities: build_document_projection_capabilities(child_count),
|
||||
resource_meta: Some(KernelProjectionResourceMeta {
|
||||
resource_kind: Some(KernelProjectionResourceKind::Document),
|
||||
document_id: Some(node.id.clone()),
|
||||
asset_id: None,
|
||||
workspace_id: node.workspace_id.clone(),
|
||||
asset_kind: None,
|
||||
icon_hint: Some("page".into()),
|
||||
extra: BTreeMap::new(),
|
||||
}),
|
||||
icon_hint: Some("page".into()),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(KernelProjectionResult {
|
||||
@@ -4083,10 +4724,15 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.title.update" => {
|
||||
"documents.title.update" | "tree.node.rename" => {
|
||||
let payload: DocumentTitleCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.node.rename" {
|
||||
"tree.node.rename"
|
||||
} else {
|
||||
"documents.title.update"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.title.update".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -4383,11 +5029,16 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.create" => {
|
||||
"documents.create" | "tree.node.create" => {
|
||||
let payload: DocumentCreateCommandPayload =
|
||||
parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.node.create" {
|
||||
"tree.node.create"
|
||||
} else {
|
||||
"documents.create"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.create".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -4420,10 +5071,15 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.move" => {
|
||||
"documents.move" | "tree.subtree.move" => {
|
||||
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.subtree.move" {
|
||||
"tree.subtree.move"
|
||||
} else {
|
||||
"documents.move"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.move".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -5839,12 +6495,157 @@ mod tests {
|
||||
|
||||
assert_eq!(result["projection"], json!("sidebar_tree"));
|
||||
assert_eq!(result["rootNodeId"], json!("page_root"));
|
||||
assert_eq!(result["items"][0]["rowId"], json!("page:page_root"));
|
||||
assert_eq!(result["items"][0]["rowKind"], json!("document"));
|
||||
assert_eq!(result["items"][0]["nodeId"], json!("page_root"));
|
||||
assert_eq!(result["items"][0]["position"], json!(0));
|
||||
assert_eq!(result["items"][0]["projectionKind"], json!("sidebar_tree"));
|
||||
assert_eq!(result["items"][0]["resourceMeta"]["resourceKind"], json!("document"));
|
||||
assert!(
|
||||
result["items"][0]["capabilities"]
|
||||
.as_array()
|
||||
.map(|caps| caps.contains(&json!("create-child")))
|
||||
.unwrap_or(false)
|
||||
);
|
||||
assert_eq!(result["items"][1]["parentNodeId"], json!("page_root"));
|
||||
assert_eq!(result["items"][1]["position"], json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_project_view_query_executes_into_file_tree_projection_contract() {
|
||||
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"
|
||||
},
|
||||
{
|
||||
"id": "page_child",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "子页面",
|
||||
"parent_id": "page_root",
|
||||
"sort_order": 2,
|
||||
"is_starred": false,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"media_assets": [
|
||||
{
|
||||
"id": "asset_img_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"asset_type": "file",
|
||||
"file_name": "cover.png",
|
||||
"mime_type": "image/png",
|
||||
"storage_path": "cover.png"
|
||||
},
|
||||
{
|
||||
"id": "asset_pdf_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_child",
|
||||
"asset_type": "file",
|
||||
"file_name": "notes.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"storage_path": "notes.pdf"
|
||||
},
|
||||
{
|
||||
"id": "asset_book_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_child",
|
||||
"asset_type": "file",
|
||||
"file_name": "guide.epub",
|
||||
"mime_type": "application/epub+zip",
|
||||
"storage_path": "guide.epub"
|
||||
},
|
||||
{
|
||||
"id": "asset_ref_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"asset_type": "file",
|
||||
"file_name": "diagram-source.png",
|
||||
"mime_type": "image/png",
|
||||
"storage_path": "diagram-source.png"
|
||||
}
|
||||
],
|
||||
"mindmap_assets": [
|
||||
{
|
||||
"id": "mind_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"asset_type": "mindmap",
|
||||
"file_name": "roadmap.json",
|
||||
"mime_type": "application/json",
|
||||
"storage_path": "roadmap.json"
|
||||
}
|
||||
],
|
||||
"table_assets": [
|
||||
{
|
||||
"id": "table_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"asset_type": "luckysheet",
|
||||
"file_name": "budget.luckysheet",
|
||||
"mime_type": "application/json",
|
||||
"storage_path": "budget.luckysheet"
|
||||
}
|
||||
],
|
||||
"mindmap_asset_children": {
|
||||
"mind_1": ["asset_ref_1"]
|
||||
}
|
||||
})),
|
||||
})
|
||||
.expect("file_tree projection result 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!(result["projection"], json!("file_tree"));
|
||||
assert_eq!(result["projectionId"], json!("kernel_projection:file_tree:page_root"));
|
||||
assert_eq!(item_by_row_id["doc:page_root"]["rowKind"], json!("document"));
|
||||
assert_eq!(item_by_row_id["doc:page_root"]["resourceMeta"]["resourceKind"], json!("document"));
|
||||
assert_eq!(item_by_row_id["index:page_root"]["rowKind"], json!("index"));
|
||||
assert_eq!(item_by_row_id["index:page_root"]["resourceMeta"]["resourceKind"], json!("index"));
|
||||
assert_eq!(item_by_row_id["asset:asset_img_1"]["resourceMeta"]["assetKind"], json!("image"));
|
||||
assert_eq!(item_by_row_id["asset:asset_img_1"]["resourceMeta"]["resourceKind"], json!("asset"));
|
||||
assert_eq!(item_by_row_id["asset-folder:mind_1"]["rowKind"], json!("asset_folder"));
|
||||
assert_eq!(item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], json!("mindmap"));
|
||||
assert_eq!(item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["assetKind"], json!("mindmap"));
|
||||
assert_eq!(item_by_row_id["asset:asset_ref_1"]["parentNodeId"], json!("asset-folder:mind_1"));
|
||||
assert_eq!(item_by_row_id["asset:asset_ref_1"]["capabilities"][0], json!("open-asset"));
|
||||
assert_eq!(item_by_row_id["asset:table_1"]["resourceMeta"]["resourceKind"], json!("table"));
|
||||
assert_eq!(item_by_row_id["asset:asset_pdf_1"]["resourceMeta"]["resourceKind"], json!("pdf"));
|
||||
assert_eq!(item_by_row_id["asset:asset_book_1"]["resourceMeta"]["resourceKind"], json!("book"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_subtree_query_executes_into_unified_subtree() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
|
||||
Reference in New Issue
Block a user