use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::web_shell::{ build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html, }; use crate::ssr::pages::mindmap::MindmapPage; use crate::workspace_shell::render_workspace_shell_sidebar_html; use axum::extract::{Extension, Path, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; use serde_json::json; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MindmapShellQuery { pub source_kind: Option, pub root_uri: Option, pub workspace_id: Option, } pub async fn mindmap_object_shell( State(state): State, Extension(context): Extension, Path((doc_id, mindmap_id)): Path<(String, String)>, Query(query): Query, ) -> Result { let default_workspace_name = default_workspace_name_for_context(&state, &context); let source_kind = query.source_kind.as_deref(); let root_uri = query.root_uri.as_deref(); let aggregate = build_page_aggregate_snapshot( &state, &context, &doc_id, query.workspace_id.as_deref(), source_kind, root_uri, ) .await .ok(); let workspace_id = aggregate .as_ref() .map(|value| value.identity.workspace_id.clone()) .filter(|value| !value.trim().is_empty()); let title = aggregate .as_ref() .map(|value| value.head.title.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "思维导图".to_string()); let is_local_folder = source_kind.map(str::trim) == Some("local_folder"); let (workspace_name, sidebar_tree_html, workspace_sidebar_html) = if is_local_folder { let root_uri = root_uri.unwrap_or_default(); let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, Some(&doc_id)).unwrap_or_default(); let file_tree_html = render_local_file_tree_html(root_uri, Some(&doc_id), None).unwrap_or_default(); let workspace_projection = load_workspace_shell_projection( Some(&state), state.config(), &context, workspace_id.as_deref().unwrap_or("local-folder"), Some(&doc_id), &default_workspace_name, ) .await; let workspace_name = workspace_projection.workspace_name.clone(); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, None, ); ( Some(workspace_name), Some(sidebar_tree_html), Some(workspace_sidebar_html), ) } else if let Some(workspace_id) = workspace_id.as_deref() { let workspace_projection = load_workspace_shell_projection( Some(&state), state.config(), &context, workspace_id, Some(&doc_id), &default_workspace_name, ) .await; let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id)) .await .unwrap_or_default(); let active_filetree_row_id = format!("asset:{mindmap_id}"); let file_tree_html = load_file_tree_html( state.config(), &context, workspace_id, Some(&doc_id), Some(active_filetree_row_id.as_str()), ) .await .unwrap_or_default(); let workspace_name = workspace_projection.workspace_name.clone(); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, None, ); ( Some(workspace_name), Some(sidebar_tree_html), Some(workspace_sidebar_html), ) } else { (None, None, None) }; let editor_bootstrap = json!({ "documentId": format!("__mindmap_object__:{doc_id}:{mindmap_id}"), "workspaceId": workspace_id .as_ref() .map(|value| format!("__mindmap_object__:{value}")), "title": title.clone(), "content": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "mindmapId": mindmap_id, "mnoteBlockType": "mindmap", "projectionVersion": 1, "rootNodeId": "root", "mnoteMindmapData": serde_json::Value::Null } } ] }, "readOnly": false, "editable": true, "standaloneObject": { "kind": "mindmap", "documentId": doc_id, "mindmapId": mindmap_id }, "sourceKind": source_kind.unwrap_or("local_folder"), "rootUri": root_uri.unwrap_or(""), "revision": serde_json::Value::Null, "conflictDetectionKey": serde_json::Value::Null, "pageOptions": { "pageWidth": "full", "smallText": false, "showHeadingNumbers": false, "fontFamily": "sans" } }); let contract = json!({ "schema": "mnote.mindmap_shell.v1", "owner": "mnote-web", "shell": "mindmap", "documentId": doc_id, "mindmapId": mindmap_id, "sourceKind": source_kind.unwrap_or("local_folder"), "rootUri": root_uri.unwrap_or(""), "projection": { "schema": "mnote.mindmap.simple_mind_map_scene.v1", "runtime": "simple-mind-map", "source": "compat-blob", "owner": "rust-kernel", "queryName": "mindmap.simple_mind_map_scene.get" }, "island": { "kind": "leptos_mindmap_adapter", "mountId": "mnote-mindmap-island", "runtimeRole": "simple_mind_map_adapter", "commandName": "mindmap.command.apply" }, "requestId": context.trace.request_id, "traceId": context.trace.trace_id }); let editor_bootstrap_json = serde_json::to_string(&editor_bootstrap).unwrap_or_else(|_| "null".to_string()); let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string()); let body_content = crate::ssr::render_view(leptos::view! { }); let html = format!( r#" {} {} {} "#, escape_html(&title), crate::ssr::MNOTE_CSS, escape_html(&doc_id), escape_html(&mindmap_id), escape_html(source_kind.unwrap_or("local_folder")), escape_html(root_uri.unwrap_or("")), body_content, escape_script_json(&editor_bootstrap_json), escape_script_json(&contract_json), render_mindmap_standalone_bootstrap_script(), ); let mut response = Html(html).into_response(); stamp_shell_headers(response.headers_mut(), "mindmap"); Ok(response) } fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); } if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) { headers.insert(name, HeaderValue::from_static(shell)); } } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn escape_script_json(value: &str) -> String { value.replace(" String { r#""# .replace( "__MNOTE_DEV_HOT_BUSTER__", crate::routes::dev_hot::dev_hot_cache_buster().unwrap_or(""), ) } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some( serde_json::json!({ "sidebar:datasetList": { "active_workspace_id": "ws_demo", "documents": [ { "id": "doc_1", "workspace_id": "ws_demo", "title": "页面", "parent_id": null, "sort_order": 0 } ], "media_assets": [], "mindmap_assets": [ { "id": "mind_1", "workspace_id": "ws_demo", "document_id": "doc_1", "asset_type": "mindmap", "file_name": "思维导图.json", "mime_type": "application/json" } ], "table_assets": [], "trashed_documents": [], "trashed_media_assets": [], "trashed_mindmap_assets": [], "trashed_table_assets": [], "mindmap_docs": [], "mindmap_asset_children": {} }, "documents:getMeta": { "id": "doc_1", "workspace_id": "ws_demo", "title": "页面" }, "documents:getContent": { "content": [], "revision": 1, "conflict_detection_key": "doc_1:1", "pageSubtree": {"rootNodeId": "doc_1", "outline": []} } }) .to_string(), ), mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } fn app_with_mindmap_fixture() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some( serde_json::json!({ "mindmaps:get": { "ok": true, "source": "compat-blob", "revision": 7, "data": { "data": {"uid": "root", "text": "KMIND"}, "children": [ {"data": {"uid": "topic", "text": "二级节点"}, "children": []} ] }, "meta": { "document_id": "doc_1", "mindmap_id": "mind_1" } } }) .to_string(), ), mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } #[tokio::test] async fn mindmap_shell_returns_rust_object_shell_contract() { let response = app() .oneshot( Request::builder() .uri("/mindmap/doc_1/mind_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); assert_eq!( response .headers() .get("x-mnote-web-shell") .and_then(|value| value.to_str().ok()), Some("mindmap") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("mnote.mindmap_shell.v1")); assert!(html.contains("data-mnote-object-editor=\"mindmap\"")); assert!(html.contains("data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\"")); assert!(html.contains("data-leptos-mindmap-island=\"standalone\"")); assert!(html.contains("mindmap.simple_mind_map_scene.get")); assert!(html.contains("mindmap.command.apply")); assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__")); assert!(html.contains("mnoteBlockType")); assert!(html.contains("__mindmap_object__:doc_1:mind_1")); assert!(html.contains("\"standaloneObject\"")); assert!(html.contains("\"documentId\":\"doc_1\"")); assert!(html.contains("\"mindmapId\":\"mind_1\"")); assert!(html.contains("data-row-id=\"asset:mind_1\"")); assert!(html.contains("data-selected=\"true\"")); assert!(!html.contains("data-row-id=\"doc:doc_1\" data-row-kind=\"document\" data-node-id=\"doc_1\" data-document-id=\"doc_1\" data-doc-id=\"doc_1\" data-asset-id=\"\" data-object-identity=\"{"objectKind":"page","documentId":"doc_1","blockId":null,"assetId":null}\" data-shell-mode=\"filetree\" data-selected=\"true\"")); assert!(html.contains("runtimeModule.mount(mountTarget, bootstrap)")); assert!(html.contains("/api/leptos-tiptap-runtime/manifest.json")); assert!(!html.contains("react_mindmap_runtime")); assert!(!html.contains("next-app-router")); } #[test] fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() { let _guard = crate::test_support::hermes_env_lock() .lock() .expect("env lock"); std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); let script = super::render_mindmap_standalone_bootstrap_script(); std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); assert!(script.contains("const DEV_HOT_BUSTER = \"")); assert!(script.contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))")); assert!( script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)") ); assert!( script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)") ); } #[tokio::test] async fn mindmap_api_returns_same_adapter_contract_for_standalone_and_block() { let response = app_with_mindmap_fixture() .oneshot( Request::builder() .uri("/api/mindmap/doc_1/mind_1?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get") .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: serde_json::Value = serde_json::from_slice(&body).expect("adapter projection json"); assert_eq!( payload["schema"], serde_json::json!("mnote.mindmap.simple_mind_map_scene.v1") ); assert_eq!(payload["runtime"], serde_json::json!("simple-mind-map")); assert_eq!(payload["root"]["data"]["uid"], serde_json::json!("root")); assert_eq!(payload["root"]["data"]["text"], serde_json::json!("KMIND")); assert_eq!(payload["kernelRevision"], serde_json::json!(7)); assert_eq!( payload["compatPayload"]["source"], serde_json::json!("compat-blob") ); } }