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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ pub enum KernelNodeType {
|
||||
Page,
|
||||
Section,
|
||||
Asset,
|
||||
Table,
|
||||
Book,
|
||||
Pdf,
|
||||
Mindmap,
|
||||
@@ -48,6 +49,61 @@ pub enum KernelProjectionKind {
|
||||
RagIndex,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum KernelProjectionCapability {
|
||||
Expand,
|
||||
Open,
|
||||
Drag,
|
||||
Drop,
|
||||
Select,
|
||||
CreateChild,
|
||||
Rename,
|
||||
Archive,
|
||||
Restore,
|
||||
ContextMenu,
|
||||
Reorder,
|
||||
OpenAsset,
|
||||
Pick,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelProjectionRowKind {
|
||||
Document,
|
||||
Index,
|
||||
Asset,
|
||||
AssetFolder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelProjectionResourceKind {
|
||||
Workspace,
|
||||
Document,
|
||||
Index,
|
||||
Asset,
|
||||
AssetFolder,
|
||||
Mindmap,
|
||||
Table,
|
||||
Book,
|
||||
Pdf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelProjectionAssetKind {
|
||||
File,
|
||||
Mindmap,
|
||||
Table,
|
||||
Book,
|
||||
Pdf,
|
||||
Image,
|
||||
Video,
|
||||
Audio,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelGraphDirection {
|
||||
@@ -255,14 +311,35 @@ pub struct KernelGraphTraversalResult {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelProjectionItem {
|
||||
pub row_id: String,
|
||||
pub row_kind: KernelProjectionRowKind,
|
||||
pub node_id: String,
|
||||
pub parent_node_id: Option<String>,
|
||||
pub node_type: KernelNodeType,
|
||||
pub projection_kind: KernelProjectionKind,
|
||||
pub title: Option<String>,
|
||||
pub depth: u32,
|
||||
pub position: Option<i64>,
|
||||
pub child_count: u32,
|
||||
pub expandable: bool,
|
||||
pub expanded_by_default: bool,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<KernelProjectionCapability>,
|
||||
pub resource_meta: Option<KernelProjectionResourceMeta>,
|
||||
pub icon_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelProjectionResourceMeta {
|
||||
pub resource_kind: Option<KernelProjectionResourceKind>,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub asset_kind: Option<KernelProjectionAssetKind>,
|
||||
pub icon_hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -394,4 +471,45 @@ mod tests {
|
||||
assert_eq!(value["refs"]["referenceNodeIds"], json!(["page_1"]));
|
||||
assert_eq!(value["audit"]["traceId"], json!("trace_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_projection_item_serializes_contract_fields() {
|
||||
let item = KernelProjectionItem {
|
||||
row_id: "asset:page_1".into(),
|
||||
row_kind: KernelProjectionRowKind::Asset,
|
||||
node_id: "page_1".into(),
|
||||
parent_node_id: None,
|
||||
node_type: KernelNodeType::Page,
|
||||
projection_kind: KernelProjectionKind::FileTree,
|
||||
title: Some("页面 1".into()),
|
||||
depth: 0,
|
||||
position: Some(1),
|
||||
child_count: 2,
|
||||
expandable: true,
|
||||
expanded_by_default: true,
|
||||
capabilities: vec![
|
||||
KernelProjectionCapability::Expand,
|
||||
KernelProjectionCapability::Open,
|
||||
KernelProjectionCapability::CreateChild,
|
||||
],
|
||||
resource_meta: Some(KernelProjectionResourceMeta {
|
||||
resource_kind: Some(KernelProjectionResourceKind::Document),
|
||||
document_id: Some("page_1".into()),
|
||||
asset_id: None,
|
||||
workspace_id: Some("ws_1".into()),
|
||||
asset_kind: Some(KernelProjectionAssetKind::File),
|
||||
icon_hint: Some("page".into()),
|
||||
extra: BTreeMap::new(),
|
||||
}),
|
||||
icon_hint: Some("page".into()),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&item).expect("projection item 应可序列化");
|
||||
assert_eq!(value["rowId"], json!("asset:page_1"));
|
||||
assert_eq!(value["rowKind"], json!("asset"));
|
||||
assert_eq!(value["projectionKind"], json!("file_tree"));
|
||||
assert_eq!(value["capabilities"], json!(["expand", "open", "create-child"]));
|
||||
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
|
||||
assert_eq!(value["iconHint"], json!("page"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ pub use kernel::{
|
||||
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
|
||||
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
|
||||
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
|
||||
KernelNodeType, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResult, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelNodeType, KernelProjectionAssetKind, KernelProjectionCapability,
|
||||
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
||||
};
|
||||
pub use mindmap::{
|
||||
|
||||
@@ -10,6 +10,7 @@ axum = { version = "0.8", features = ["ws"] }
|
||||
bridge-runtime = { path = "../bridge-runtime" }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
futures-util = "0.3"
|
||||
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct TraceContext {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthContext {
|
||||
pub authorization: Option<String>,
|
||||
pub cookie_header: Option<String>,
|
||||
pub actor_id: String,
|
||||
pub actor_type: String,
|
||||
pub session_id: Option<String>,
|
||||
@@ -76,6 +77,7 @@ impl RequestContext {
|
||||
},
|
||||
auth: AuthContext {
|
||||
authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()),
|
||||
cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()),
|
||||
actor_id: header_value(headers, HEADER_ACTOR_ID)
|
||||
.unwrap_or_else(|| "anonymous".into()),
|
||||
actor_type: header_value(headers, HEADER_ACTOR_TYPE)
|
||||
|
||||
@@ -4,5 +4,6 @@ pub mod error;
|
||||
pub mod middleware;
|
||||
pub mod routes;
|
||||
pub mod transport;
|
||||
pub mod tree_shell;
|
||||
|
||||
pub use app::{build_app, AppConfig, AppState};
|
||||
|
||||
@@ -4,11 +4,8 @@ use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
pub async fn inject_request_context(mut request: Request, next: Next) -> Response {
|
||||
let context = RequestContext::from_http_parts(
|
||||
request.method(),
|
||||
request.uri(),
|
||||
request.headers(),
|
||||
);
|
||||
let context =
|
||||
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
@@ -78,7 +78,11 @@ pub fn build_tree_target(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_non_empty(value: &str, field: &'static str, context: &RequestContext) -> Result<String, WebError> {
|
||||
pub fn ensure_non_empty(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
context: &RequestContext,
|
||||
) -> Result<String, WebError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -99,12 +103,11 @@ pub fn read_optional_non_empty(value: Option<String>) -> Option<String> {
|
||||
|
||||
pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result<i64, WebError> {
|
||||
if sort_order < 0 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
"sortOrder 不能小于 0",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate"));
|
||||
return Err(
|
||||
WebError::bad_request_code("tree_command_validation", "sortOrder 不能小于 0")
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate"),
|
||||
);
|
||||
}
|
||||
Ok(sort_order)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
load_projection_snapshot, ProjectionSnapshotSpec,
|
||||
};
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use axum::extract::Query;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::StatusCode;
|
||||
@@ -77,10 +75,7 @@ pub async fn next_sidebar(
|
||||
.with_header("x-error-phase", "compat_sidebar_shape")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
dataset_object.insert(
|
||||
"kernel_sidebar_projection".into(),
|
||||
snapshot.projection,
|
||||
);
|
||||
dataset_object.insert("kernel_sidebar_projection".into(), snapshot.projection);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
@@ -59,10 +57,11 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Val
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn project_sidebar(
|
||||
async fn project_projection(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<KernelProjectionQuery>,
|
||||
projection: KernelProjectionKind,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
@@ -74,7 +73,7 @@ pub async fn project_sidebar(
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: query.root_node_id.as_deref(),
|
||||
depth: query.depth,
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
projection,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -82,6 +81,38 @@ pub async fn project_sidebar(
|
||||
Ok(ok_response(&context, snapshot.projection))
|
||||
}
|
||||
|
||||
pub async fn project_tree_sidebar(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_projection(state, context, query, KernelProjectionKind::SidebarTree).await
|
||||
}
|
||||
|
||||
pub async fn project_tree_page(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_projection(state, context, query, KernelProjectionKind::PageTree).await
|
||||
}
|
||||
|
||||
pub async fn project_tree_file(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_projection(state, context, query, KernelProjectionKind::FileTree).await
|
||||
}
|
||||
|
||||
pub async fn project_sidebar(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_tree_sidebar(state, context, query).await
|
||||
}
|
||||
|
||||
pub async fn subtree(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -154,8 +185,9 @@ pub async fn graph(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::Body;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -168,7 +200,7 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","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_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","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_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -190,4 +222,169 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_contract_route_keeps_shared_fields() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/kernel/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let first = &payload["result"]["items"][0];
|
||||
assert_eq!(first["projectionKind"], "sidebar_tree");
|
||||
assert_eq!(first["resourceMeta"]["resourceKind"], "document");
|
||||
assert_eq!(first["iconHint"], "page");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_return_sidebar_page_and_file_projection_kinds() {
|
||||
let cases = [
|
||||
(
|
||||
"/api/tree/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root",
|
||||
"sidebar_tree",
|
||||
),
|
||||
(
|
||||
"/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root",
|
||||
"page_tree",
|
||||
),
|
||||
(
|
||||
"/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root",
|
||||
"file_tree",
|
||||
),
|
||||
];
|
||||
|
||||
for (uri, expected_projection_kind) in cases {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["projection"], expected_projection_kind);
|
||||
assert_eq!(
|
||||
payload["result"]["items"][0]["projectionKind"],
|
||||
expected_projection_kind
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_keep_ok_response_shape() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["result"]["projection"], "page_tree");
|
||||
assert_eq!(payload["result"]["rootNodeId"], "page_root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_tree_projection_includes_index_and_asset_rows() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let items = payload["result"]["items"].as_array().expect("items");
|
||||
let row_kinds = items
|
||||
.iter()
|
||||
.filter_map(|item| item["rowKind"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let resource_kinds = items
|
||||
.iter()
|
||||
.filter_map(|item| item["resourceMeta"]["resourceKind"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(row_kinds.contains(&"document"));
|
||||
assert!(row_kinds.contains(&"index"));
|
||||
assert!(row_kinds.contains(&"asset"));
|
||||
assert!(row_kinds.contains(&"asset_folder"));
|
||||
assert!(resource_kinds.contains(&"document"));
|
||||
assert!(resource_kinds.contains(&"index"));
|
||||
assert!(resource_kinds.contains(&"asset"));
|
||||
assert!(resource_kinds.contains(&"mindmap"));
|
||||
assert!(resource_kinds.contains(&"table"));
|
||||
assert_eq!(items[0]["nodeId"], "page_root");
|
||||
assert_eq!(items[1]["nodeId"], "index:page_root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_tree_projection_resource_variants_keep_icon_hint_and_capabilities() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let item_by_row_id = payload["result"]["items"]
|
||||
.as_array()
|
||||
.expect("items")
|
||||
.iter()
|
||||
.filter_map(|item| item["rowId"].as_str().map(|row_id| (row_id, item)))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(item_by_row_id["asset:asset_pdf_1"]["iconHint"], "pdf");
|
||||
assert_eq!(item_by_row_id["asset:asset_book_1"]["iconHint"], "book");
|
||||
assert_eq!(item_by_row_id["asset:table_1"]["iconHint"], "table");
|
||||
assert_eq!(item_by_row_id["asset-folder:mind_1"]["iconHint"], "mindmap");
|
||||
assert_eq!(
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
|
||||
"mindmap"
|
||||
);
|
||||
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value == "expand"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/tree", get(tree::tree_shell))
|
||||
.route(
|
||||
"/api/tree/projections/sidebar",
|
||||
get(kernel::project_tree_sidebar),
|
||||
)
|
||||
.route("/api/tree/projections/page", get(kernel::project_tree_page))
|
||||
.route("/api/tree/projections/file", get(kernel::project_tree_file))
|
||||
.route(
|
||||
"/api/kernel/projections/sidebar",
|
||||
get(kernel::project_sidebar),
|
||||
|
||||
@@ -126,6 +126,7 @@ pub async fn execute_runtime_query_via_convex(
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
|
||||
let data =
|
||||
fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
|
||||
execute_runtime_query_against_data(context, effective_workspace_id, query, data)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,8 @@ use crate::routes::command_support::{
|
||||
build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex,
|
||||
read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
load_projection_snapshot, ProjectionSnapshotSpec,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
@@ -118,7 +114,12 @@ fn normalize_tree_mode(value: Option<&str>) -> &'static str {
|
||||
}
|
||||
|
||||
fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
|
||||
match value.unwrap_or_default().trim().to_ascii_lowercase().as_str() {
|
||||
match value
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"1" | "true" | "yes" | "on" => true,
|
||||
"0" | "false" | "no" | "off" => false,
|
||||
_ => default,
|
||||
@@ -812,6 +813,7 @@ fn build_tree_shell_html(
|
||||
nodeId: normalizeText(item?.nodeId),
|
||||
parentNodeId: normalizeParent(item?.parentNodeId),
|
||||
title: normalizeText(item?.title, "无标题"),
|
||||
depth: normalizeNumber(item?.depth, 0),
|
||||
childCount: normalizeNumber(item?.childCount, 0),
|
||||
position: normalizeNumber(item?.position),
|
||||
expandedByDefault: item?.expandedByDefault !== false,
|
||||
@@ -971,6 +973,17 @@ fn build_tree_shell_html(
|
||||
<circle cx="12" cy="8" r="1.2"/>
|
||||
</svg>
|
||||
`,
|
||||
edit: `
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M3.4 11.8 3 13l1.2-.4 6.6-6.6-1.4-1.4-6 6.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
|
||||
<path d="m9.9 4.6 1.5-1.5a1 1 0 0 1 1.4 0l.6.6a1 1 0 0 1 0 1.4l-1.5 1.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
|
||||
</svg>
|
||||
`,
|
||||
up: `
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 12.6V4.2M8 4.2 5.4 6.8M8 4.2l2.6 2.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`,
|
||||
page: `
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
@@ -1422,6 +1435,8 @@ fn build_tree_shell_html(
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tree-actions";
|
||||
const siblingRows = getSiblings(item.parentNodeId);
|
||||
const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId);
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.add,
|
||||
@@ -1432,6 +1447,24 @@ fn build_tree_shell_html(
|
||||
),
|
||||
);
|
||||
if (mode === "page") {
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.edit,
|
||||
"tree-action-rename",
|
||||
`重命名 ${item.title}`,
|
||||
() => void handleRename(item.nodeId),
|
||||
false,
|
||||
),
|
||||
);
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.up,
|
||||
"tree-action-move-up",
|
||||
`上移 ${item.title}`,
|
||||
() => void handleMove(item.nodeId, -1),
|
||||
siblingIndex <= 0,
|
||||
),
|
||||
);
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.more,
|
||||
@@ -1916,9 +1949,10 @@ pub async fn tree_shell(
|
||||
&snapshot.dataset,
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -1938,9 +1972,10 @@ fn create_command_wire(
|
||||
} => {
|
||||
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
||||
let parent_id = read_optional_non_empty(parent_id);
|
||||
let access_scope = read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
|
||||
let access_scope =
|
||||
read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
|
||||
Ok(RuntimeCommandEnvelopeWire {
|
||||
name: "documents.create".into(),
|
||||
name: "tree.node.create".into(),
|
||||
command_id: format!("tree_create_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: bridge_runtime::RuntimeActorWire {
|
||||
@@ -1952,7 +1987,11 @@ fn create_command_wire(
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
||||
target: Some(build_tree_target(
|
||||
workspace_id,
|
||||
Some(document_id.as_str()),
|
||||
None,
|
||||
)),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
@@ -1974,7 +2013,7 @@ fn create_command_wire(
|
||||
} => {
|
||||
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
||||
Ok(RuntimeCommandEnvelopeWire {
|
||||
name: "documents.title.update".into(),
|
||||
name: "tree.node.rename".into(),
|
||||
command_id: format!("tree_rename_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: bridge_runtime::RuntimeActorWire {
|
||||
@@ -1986,7 +2025,11 @@ fn create_command_wire(
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
||||
target: Some(build_tree_target(
|
||||
workspace_id,
|
||||
Some(document_id.as_str()),
|
||||
None,
|
||||
)),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"title": title,
|
||||
@@ -2007,7 +2050,7 @@ fn create_command_wire(
|
||||
let sort_order = ensure_sort_order(sort_order, context)?;
|
||||
let parent_id = read_optional_non_empty(parent_id);
|
||||
Ok(RuntimeCommandEnvelopeWire {
|
||||
name: "documents.move".into(),
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: format!("tree_move_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: bridge_runtime::RuntimeActorWire {
|
||||
@@ -2019,7 +2062,11 @@ fn create_command_wire(
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
||||
target: Some(build_tree_target(
|
||||
workspace_id,
|
||||
Some(document_id.as_str()),
|
||||
None,
|
||||
)),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"parentId": parent_id,
|
||||
@@ -2097,9 +2144,7 @@ pub async fn tree_command(
|
||||
None,
|
||||
),
|
||||
TreeCommandRequest::Rename {
|
||||
document_id,
|
||||
title,
|
||||
..
|
||||
document_id, title, ..
|
||||
} => (
|
||||
"rename",
|
||||
document_id.clone(),
|
||||
@@ -2154,9 +2199,12 @@ pub async fn tree_command(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_command_wire, TreeCommandRequest};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2277,7 +2325,10 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["action"], Value::String("create".into()));
|
||||
assert_eq!(payload["result"]["documentId"], Value::String("page_new".into()));
|
||||
assert_eq!(
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_new".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2345,6 +2396,199 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["action"], Value::String("move".into()));
|
||||
assert_eq!(payload["result"]["documentId"], Value::String("page_child".into()));
|
||||
assert_eq!(
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_child".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.header("Authorization", "Bearer demo-token")
|
||||
.body(Body::from(
|
||||
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":"命名"}"#,
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert!(payload["requestId"].as_str().is_some());
|
||||
assert!(payload["traceId"].as_str().is_some());
|
||||
assert_eq!(payload["result"]["workspaceId"], "ws_demo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_route_contracts_compat_sidebar_keeps_boundary_and_trace_fields() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/compat/next/sidebar?workspaceId=ws_demo")
|
||||
.header("Authorization", "Bearer demo-token")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["boundary"], "next_sidebar_compat");
|
||||
assert_eq!(payload["workspaceId"], "ws_demo");
|
||||
assert!(payload["requestId"].as_str().is_some());
|
||||
assert!(payload["traceId"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_commands_prefer_tree_protocol_names_in_command_wire() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/tree/commands".parse::<Uri>().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
|
||||
let create_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Create {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_new".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
title: "新页面".into(),
|
||||
access_scope: Some("private".into()),
|
||||
content: Some(Value::Array(Vec::new())),
|
||||
},
|
||||
)
|
||||
.expect("create wire");
|
||||
assert_eq!(create_wire.name, "tree.node.create");
|
||||
|
||||
let rename_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Rename {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
title: "重命名".into(),
|
||||
},
|
||||
)
|
||||
.expect("rename wire");
|
||||
assert_eq!(rename_wire.name, "tree.node.rename");
|
||||
|
||||
let move_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Move {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
sort_order: 1,
|
||||
},
|
||||
)
|
||||
.expect("move wire");
|
||||
assert_eq!(move_wire.name, "tree.subtree.move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/tree/commands".parse::<Uri>().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
|
||||
let tree_create_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Create {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_new".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
title: "新页面".into(),
|
||||
access_scope: Some("private".into()),
|
||||
content: Some(Value::Array(Vec::new())),
|
||||
},
|
||||
)
|
||||
.expect("tree create wire");
|
||||
let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.create".into(),
|
||||
..tree_create_wire.clone()
|
||||
};
|
||||
let tree_create_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire)
|
||||
.expect("tree create plan");
|
||||
let compat_create_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
|
||||
.expect("compat create plan");
|
||||
assert_eq!(
|
||||
tree_create_plan.function_name,
|
||||
"documents:createWithParentReference"
|
||||
);
|
||||
assert_eq!(
|
||||
tree_create_plan.function_name,
|
||||
compat_create_plan.function_name
|
||||
);
|
||||
|
||||
let tree_rename_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Rename {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
title: "重命名".into(),
|
||||
},
|
||||
)
|
||||
.expect("tree rename wire");
|
||||
let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.title.update".into(),
|
||||
..tree_rename_wire.clone()
|
||||
};
|
||||
let tree_rename_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire)
|
||||
.expect("tree rename plan");
|
||||
let compat_rename_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
|
||||
.expect("compat rename plan");
|
||||
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
|
||||
assert_eq!(
|
||||
tree_rename_plan.function_name,
|
||||
compat_rename_plan.function_name
|
||||
);
|
||||
|
||||
let tree_move_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Move {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
sort_order: 1,
|
||||
},
|
||||
)
|
||||
.expect("tree move wire");
|
||||
let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.move".into(),
|
||||
..tree_move_wire.clone()
|
||||
};
|
||||
let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire)
|
||||
.expect("tree move plan");
|
||||
let compat_move_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
|
||||
.expect("compat move plan");
|
||||
assert_eq!(tree_move_plan.function_name, "documents:move");
|
||||
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ async fn handle_socket(
|
||||
query: StreamSnapshotQuery,
|
||||
payload: Value,
|
||||
) {
|
||||
let _ = socket
|
||||
.send(serialize_snapshot_message(&payload))
|
||||
.await;
|
||||
let _ = socket.send(serialize_snapshot_message(&payload)).await;
|
||||
|
||||
while let Some(message) = socket.next().await {
|
||||
let Ok(message) = message else {
|
||||
@@ -43,7 +41,11 @@ async fn handle_socket(
|
||||
if is_resync_request(&text) {
|
||||
match load_stream_snapshot(state.config(), &context, &query).await {
|
||||
Ok(snapshot) => {
|
||||
if socket.send(serialize_resync_message(&snapshot)).await.is_err() {
|
||||
if socket
|
||||
.send(serialize_resync_message(&snapshot))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +74,11 @@ async fn handle_socket(
|
||||
"accepted": false,
|
||||
"reason": "unsupported_message",
|
||||
});
|
||||
if socket.send(Message::Text(ack.to_string().into())).await.is_err() {
|
||||
if socket
|
||||
.send(Message::Text(ack.to_string().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
@@ -14,6 +12,7 @@ const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
|
||||
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
|
||||
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
|
||||
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
|
||||
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
@@ -57,6 +56,10 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
||||
return Ok(authorization.to_string());
|
||||
}
|
||||
|
||||
if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) {
|
||||
return Ok(format!("Bearer {convex_token}"));
|
||||
}
|
||||
|
||||
let admin_key = config
|
||||
.convex_admin_key
|
||||
.clone()
|
||||
@@ -71,27 +74,32 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
let effective_actor_id = if context.auth.actor_id.trim().is_empty()
|
||||
|| context.auth.actor_id == "anonymous"
|
||||
{
|
||||
config.dev_user_id.clone()
|
||||
} else {
|
||||
context.auth.actor_id.clone()
|
||||
};
|
||||
// 说明:
|
||||
// - Next 侧 `setAdminAuth(adminKey)` 走的是纯 admin auth,而不是伪造用户身份。
|
||||
// - Convex 业务函数内部会在缺少真实 token 时自行回退到 DEV_USER_ID。
|
||||
// - 这里若强行附带伪造 identity,会让 getAuthUserId(ctx) 命中一个未映射用户,
|
||||
// 反而绕过 DEV_USER_ID fallback,导致 workspace membership 校验失败。
|
||||
Ok(format!("Convex {admin_key}"))
|
||||
}
|
||||
|
||||
let identity = json!({
|
||||
"subject": effective_actor_id,
|
||||
"issuer": "https://mnote.local/dev-auth",
|
||||
"tokenIdentifier": format!("dev-user|{}", effective_actor_id),
|
||||
"name": config.dev_user_name,
|
||||
"email": config.dev_user_email,
|
||||
});
|
||||
let encoded = STANDARD.encode(
|
||||
serde_json::to_string(&identity)
|
||||
.map_err(|error| WebError::internal(format!("开发用户身份序列化失败: {error}")))?,
|
||||
);
|
||||
|
||||
Ok(format!("Convex {admin_key}:{encoded}"))
|
||||
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
||||
context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.and_then(|cookie_header| {
|
||||
cookie_header.split(';').find_map(|segment| {
|
||||
let (key, value) = segment.trim().split_once('=')?;
|
||||
if key.trim() != name {
|
||||
return None;
|
||||
}
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn convex_url(config: &AppConfig, context: &RequestContext) -> Result<String, WebError> {
|
||||
@@ -303,7 +311,10 @@ pub async fn execute_convex_command_plan(
|
||||
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"transport_command_unsupported",
|
||||
format!("mnote-web transport 暂不支持 command: {}", plan.function_name),
|
||||
format!(
|
||||
"mnote-web transport 暂不支持 command: {}",
|
||||
plan.function_name
|
||||
),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "plan_validation"));
|
||||
@@ -359,7 +370,10 @@ pub async fn execute_convex_command_plan(
|
||||
|
||||
let response = request.send().await.map_err(|error| {
|
||||
let base = if error.is_timeout() {
|
||||
WebError::gateway_timeout_code("convex_timeout", format!("Convex mutation 超时: {error}"))
|
||||
WebError::gateway_timeout_code(
|
||||
"convex_timeout",
|
||||
format!("Convex mutation 超时: {error}"),
|
||||
)
|
||||
} else {
|
||||
WebError::service_unavailable_code(
|
||||
"convex_unavailable",
|
||||
@@ -419,3 +433,77 @@ pub async fn execute_convex_command_plan(
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_authorization;
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
|
||||
|
||||
fn config() -> AppConfig {
|
||||
AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:3104".into(),
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: Some("http://127.0.0.1:3210".into()),
|
||||
convex_admin_key: Some("admin-demo".into()),
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_context(headers: HeaderMap) -> RequestContext {
|
||||
RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/compat/next/sidebar?workspaceId=ws_demo"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
&headers,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_prefers_forwarded_authorization() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"authorization",
|
||||
HeaderValue::from_static("Bearer real-token"),
|
||||
);
|
||||
|
||||
let authorization =
|
||||
build_authorization(&config(), &request_context(headers)).expect("authorization");
|
||||
|
||||
assert_eq!(authorization, "Bearer real-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_falls_back_to_plain_admin_auth() {
|
||||
let authorization = build_authorization(&config(), &request_context(HeaderMap::new()))
|
||||
.expect("authorization");
|
||||
|
||||
assert_eq!(authorization, "Convex admin-demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_reads_convex_token_from_cookie() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
HeaderValue::from_static(
|
||||
"foo=bar; mnote_web_convex_token=token-from-cookie; theme=light",
|
||||
),
|
||||
);
|
||||
|
||||
let authorization =
|
||||
build_authorization(&config(), &request_context(headers)).expect("authorization");
|
||||
|
||||
assert_eq!(authorization, "Bearer token-from-cookie");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TreeShellCommandAction {
|
||||
Create,
|
||||
Rename,
|
||||
Move,
|
||||
Archive,
|
||||
Restore,
|
||||
Purge,
|
||||
Embed,
|
||||
Copy,
|
||||
}
|
||||
|
||||
impl TreeShellCommandAction {
|
||||
pub fn preferred_command_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "tree.node.create",
|
||||
Self::Rename => "tree.node.rename",
|
||||
Self::Move => "tree.subtree.move",
|
||||
Self::Archive => "tree.node.archive",
|
||||
Self::Restore => "tree.node.restore",
|
||||
Self::Purge => "tree.node.purge",
|
||||
Self::Embed => "tree.node.embed",
|
||||
Self::Copy => "tree.subtree.copy",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compat_command_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "documents.create",
|
||||
Self::Rename => "documents.title.update",
|
||||
Self::Move => "documents.move",
|
||||
Self::Archive => "documents.delete",
|
||||
Self::Restore => "documents.restore",
|
||||
Self::Purge => "documents.purge",
|
||||
Self::Embed => "documents.embed",
|
||||
Self::Copy => "documents.copy_tree",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use super::protocol;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTreeRenderRow {
|
||||
pub row_id: String,
|
||||
pub row_kind: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let test_id = match row.row_kind.as_str() {
|
||||
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
|
||||
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
|
||||
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
|
||||
};
|
||||
(test_id, row.row_id.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TreeShellBootstrap {
|
||||
pub workspace_id: String,
|
||||
pub root_node_id: Option<String>,
|
||||
pub mode: String,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TreeShellLoader {
|
||||
pub bootstrap: TreeShellBootstrap,
|
||||
}
|
||||
|
||||
impl TreeShellLoader {
|
||||
pub fn from_bootstrap(bootstrap: TreeShellBootstrap) -> Self {
|
||||
Self { bootstrap }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
pub mod dispatcher;
|
||||
pub mod filetree_renderer;
|
||||
pub mod loader;
|
||||
pub mod page_renderer;
|
||||
pub mod picker_renderer;
|
||||
pub mod protocol;
|
||||
pub mod state;
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn TreeShellRoot(mode: &'static str, headline: String) -> impl IntoView {
|
||||
view! {
|
||||
<section data-testid="tree-shell-root" data-mode=mode>
|
||||
<header>
|
||||
<p>"mnote tree shell"</p>
|
||||
<h1>{headline}</h1>
|
||||
</header>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::dispatcher::TreeShellCommandAction;
|
||||
use super::loader::{TreeShellBootstrap, TreeShellLoader};
|
||||
use super::protocol;
|
||||
use super::state::TreeShellUiState;
|
||||
|
||||
#[test]
|
||||
fn tree_shell_leptos_scaffold_exposes_loader_dispatcher_and_state() {
|
||||
let bootstrap = TreeShellBootstrap {
|
||||
workspace_id: "ws_demo".into(),
|
||||
root_node_id: Some("page_root".into()),
|
||||
mode: protocol::MODE_PAGE.into(),
|
||||
request_id: "req_demo".into(),
|
||||
trace_id: "trace_demo".into(),
|
||||
};
|
||||
let loader = TreeShellLoader::from_bootstrap(bootstrap.clone());
|
||||
let mut state = TreeShellUiState::from_defaults(&["page_root".into()]);
|
||||
state.select("page_root");
|
||||
state.focus("page_root");
|
||||
|
||||
assert_eq!(loader.bootstrap.workspace_id, "ws_demo");
|
||||
assert_eq!(
|
||||
TreeShellCommandAction::Create.preferred_command_name(),
|
||||
"tree.node.create"
|
||||
);
|
||||
assert!(state.expanded.contains("page_root"));
|
||||
assert_eq!(state.selected.as_deref(), Some("page_root"));
|
||||
assert_eq!(state.focused.as_deref(), Some("page_root"));
|
||||
assert_eq!(bootstrap.mode, protocol::MODE_PAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::protocol;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeRenderRow {
|
||||
pub node_id: String,
|
||||
pub title: String,
|
||||
pub depth: u32,
|
||||
pub expandable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeDomRow {
|
||||
pub test_id: &'static str,
|
||||
pub action_create_test_id: &'static str,
|
||||
pub action_rename_test_id: &'static str,
|
||||
pub action_move_up_test_id: &'static str,
|
||||
pub node_id: String,
|
||||
pub depth: u32,
|
||||
pub title: String,
|
||||
pub expandable: bool,
|
||||
}
|
||||
|
||||
pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRow> {
|
||||
rows.iter()
|
||||
.map(|row| PageTreeDomRow {
|
||||
test_id: protocol::TEST_ID_TREE_NODE_OPEN,
|
||||
action_create_test_id: protocol::TEST_ID_TREE_ACTION_CREATE,
|
||||
action_rename_test_id: protocol::TEST_ID_TREE_ACTION_RENAME,
|
||||
action_move_up_test_id: protocol::TEST_ID_TREE_ACTION_MOVE_UP,
|
||||
node_id: row.node_id.clone(),
|
||||
depth: row.depth,
|
||||
title: row.title.clone(),
|
||||
expandable: row.expandable,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_page_tree_dom_rows, PageTreeRenderRow};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_page_renderer_builds_rows_with_stable_testids() {
|
||||
let rows = build_page_tree_dom_rows(&[
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_root".into(),
|
||||
title: "首页".into(),
|
||||
depth: 0,
|
||||
expandable: true,
|
||||
},
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_child".into(),
|
||||
title: "子页".into(),
|
||||
depth: 1,
|
||||
expandable: false,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].test_id, "tree-node-open");
|
||||
assert_eq!(rows[0].action_create_test_id, "tree-action-create");
|
||||
assert_eq!(rows[0].action_rename_test_id, "tree-action-rename");
|
||||
assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up");
|
||||
assert_eq!(rows[1].depth, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use super::protocol;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerRenderRow {
|
||||
pub node_id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerRenderResult {
|
||||
pub root_test_id: &'static str,
|
||||
pub item_count: usize,
|
||||
pub allow_root_pick: bool,
|
||||
}
|
||||
|
||||
pub fn build_picker_render_result(
|
||||
rows: &[PickerRenderRow],
|
||||
allow_root_pick: bool,
|
||||
) -> PickerRenderResult {
|
||||
PickerRenderResult {
|
||||
root_test_id: protocol::TEST_ID_TREE_PICKER_ROOT,
|
||||
item_count: rows.len(),
|
||||
allow_root_pick,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
|
||||
use super::{build_picker_render_result, PickerRenderRow};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
|
||||
let file_rows = build_filetree_testids(&[
|
||||
FileTreeRenderRow {
|
||||
row_id: "doc:page_root".into(),
|
||||
row_kind: "document".into(),
|
||||
title: "首页".into(),
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
row_id: "asset:asset_1".into(),
|
||||
row_kind: "asset".into(),
|
||||
title: "附件".into(),
|
||||
},
|
||||
]);
|
||||
let picker = build_picker_render_result(
|
||||
&[PickerRenderRow {
|
||||
node_id: "page_root".into(),
|
||||
title: "首页".into(),
|
||||
}],
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(file_rows[0].0, "filetree-doc-row");
|
||||
assert_eq!(file_rows[1].0, "filetree-asset-row");
|
||||
assert_eq!(picker.root_test_id, "tree-picker-root");
|
||||
assert!(picker.allow_root_pick);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub const MODE_PAGE: &str = "page";
|
||||
pub const MODE_FILETREE: &str = "filetree";
|
||||
pub const MODE_PICKER: &str = "picker";
|
||||
|
||||
pub const TEST_ID_TREE_SHELL_ROOT: &str = "tree-shell-root";
|
||||
pub const TEST_ID_TREE_NODE_OPEN: &str = "tree-node-open";
|
||||
pub const TEST_ID_TREE_NODE_TOGGLE: &str = "tree-node-toggle";
|
||||
pub const TEST_ID_TREE_ACTION_CREATE: &str = "tree-action-create";
|
||||
pub const TEST_ID_TREE_ACTION_RENAME: &str = "tree-action-rename";
|
||||
pub const TEST_ID_TREE_ACTION_MOVE_UP: &str = "tree-action-move-up";
|
||||
pub const TEST_ID_TREE_PICKER_ROOT: &str = "tree-picker-root";
|
||||
pub const TEST_ID_FILETREE_DOC_ROW: &str = "filetree-doc-row";
|
||||
pub const TEST_ID_FILETREE_INDEX_ROW: &str = "filetree-index-row";
|
||||
pub const TEST_ID_FILETREE_ASSET_ROW: &str = "filetree-asset-row";
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TreeShellUiState {
|
||||
pub expanded: BTreeSet<String>,
|
||||
pub selected: Option<String>,
|
||||
pub focused: Option<String>,
|
||||
pub keyboard_anchor: Option<String>,
|
||||
pub dragging: Option<String>,
|
||||
pub drop_target: Option<String>,
|
||||
}
|
||||
|
||||
impl TreeShellUiState {
|
||||
pub fn from_defaults(expanded: &[String]) -> Self {
|
||||
Self {
|
||||
expanded: expanded.iter().cloned().collect(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_expanded(&mut self, node_id: &str) {
|
||||
if !self.expanded.insert(node_id.to_string()) {
|
||||
self.expanded.remove(node_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select(&mut self, node_id: &str) {
|
||||
self.selected = Some(node_id.to_string());
|
||||
self.keyboard_anchor = Some(node_id.to_string());
|
||||
}
|
||||
|
||||
pub fn focus(&mut self, node_id: &str) {
|
||||
self.focused = Some(node_id.to_string());
|
||||
}
|
||||
|
||||
pub fn start_drag(&mut self, node_id: &str) {
|
||||
self.dragging = Some(node_id.to_string());
|
||||
}
|
||||
|
||||
pub fn set_drop_target(&mut self, node_id: Option<&str>) {
|
||||
self.drop_target = node_id.map(ToOwned::to_owned);
|
||||
}
|
||||
|
||||
pub fn clear_drag(&mut self) {
|
||||
self.dragging = None;
|
||||
self.drop_target = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TreeShellUiState;
|
||||
|
||||
#[test]
|
||||
fn tree_shell_interaction_state_tracks_selection_focus_keyboard_and_dragging() {
|
||||
let mut state = TreeShellUiState::from_defaults(&["page_root".into()]);
|
||||
state.toggle_expanded("page_child");
|
||||
state.select("page_child");
|
||||
state.focus("page_child");
|
||||
state.start_drag("page_child");
|
||||
state.set_drop_target(Some("page_root"));
|
||||
|
||||
assert!(state.expanded.contains("page_root"));
|
||||
assert!(state.expanded.contains("page_child"));
|
||||
assert_eq!(state.selected.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.focused.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.keyboard_anchor.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.dragging.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.drop_target.as_deref(), Some("page_root"));
|
||||
|
||||
state.clear_drag();
|
||||
assert!(state.dragging.is_none());
|
||||
assert!(state.drop_target.is_none());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
||||
"kernel.subtree.move" => "kernel:moveSubtree",
|
||||
"kernel.edge.attach" => "kernel:attachEdge",
|
||||
"kernel.edge.detach" => "kernel:detachEdge",
|
||||
"tree.node.create" => "documents:createWithParentReference",
|
||||
"tree.node.rename" => "documents:updateTitle",
|
||||
"tree.node.archive" => "documents:softDelete",
|
||||
"tree.node.restore" => "documents:restore",
|
||||
"tree.node.purge" => "documents:purge",
|
||||
"tree.subtree.move" => "documents:move",
|
||||
"tree.subtree.copy" => "documents:copyTree",
|
||||
"tree.node.embed" => "documents:updateContent",
|
||||
"documents.create" => "documents:createWithParentReference",
|
||||
"documents.move" => "documents:move",
|
||||
"documents.delete" => "documents:softDelete",
|
||||
|
||||
Reference in New Issue
Block a user