use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::error::WebError; use crate::page_aggregate::PageAggregate; use crate::routes::documents::{ load_document_content_result, load_document_meta_result, DocumentContentQuery, DocumentMetaQuery, }; use crate::routes::local_folder_source::{ load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate, }; use crate::routes::query_support::execute_runtime_query_against_data; use crate::routes::snapshot_support::{ execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec, }; use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows}; use crate::ssr::pages::document::DocumentPage; use crate::tree_shell::filetree_renderer::{ render_initial_filetree_html, FileTreeInitialRenderInput, }; use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput}; use crate::workspace_shell::{ apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html, WorkspaceShellProjection, }; use axum::body::Body; use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::json; use std::path::{Component, Path as FsPath, PathBuf}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell"; const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentShellQuery { pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub secondary_document_id: Option, pub secondary_source_kind: Option, pub secondary_root_uri: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentPageCompatQuery { pub document_id: String, pub workspace_id: Option, } pub async fn document_page_shell( State(state): State, Extension(context): Extension, Path(document_id): Path, Query(query): Query, ) -> Result { let primary_source_kind = normalize_source_kind(query.source_kind.as_deref()); let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref()); let aggregate = build_page_aggregate_snapshot( &state, &context, &document_id, query.workspace_id.as_deref(), primary_source_kind, primary_root_uri, ) .await?; let title = aggregate.head.title.as_str(); let workspace_id = aggregate.identity.workspace_id.clone(); let requested_secondary_document_id = normalize_optional_owned(query.secondary_document_id.as_deref()); let secondary_source_kind = normalize_source_kind( query .secondary_source_kind .as_deref() .or(primary_source_kind), ); let secondary_root_uri = normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri)); let mut secondary_requested = false; let mut secondary_invalid = false; let secondary_aggregate = if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() { secondary_requested = true; match build_page_aggregate_snapshot( &state, &context, secondary_document_id, query.workspace_id.as_deref(), secondary_source_kind, secondary_root_uri, ) .await { Ok(aggregate) => Some(aggregate), Err(_) => { secondary_invalid = true; None } } } else { None }; let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); let mut workspace_projection = load_workspace_shell_projection( state.config(), &context, &workspace_id, Some(&document_id), &default_workspace_name, ) .await; apply_active_page(&mut workspace_projection, Some(&document_id)); let is_local_folder = query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) == Some("local_folder"); let (sidebar_tree_html, file_tree_html) = if is_local_folder { let root_uri = query.root_uri.as_deref().unwrap_or_default(); ( render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(), render_local_file_tree_html(root_uri, Some(&document_id)).unwrap_or_default(), ) } else { ( load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id)) .await .unwrap_or_default(), load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id)) .await .unwrap_or_default(), ) }; let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), ); let workspace_name = workspace_projection.workspace_name.clone(); let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string()); let page_options_json = serde_json::to_string(&aggregate.layout.page_options) .unwrap_or_else(|_| "null".to_string()); let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string()); let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context, primary_source_kind, primary_root_uri); let secondary_page_subtree_json = secondary_aggregate.as_ref().map(|aggregate| { serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string()) }); let secondary_page_options_json = secondary_aggregate.as_ref().map(|aggregate| { serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string()) }); let secondary_snapshot_json = secondary_aggregate .as_ref() .map(|aggregate| serde_json::to_string(aggregate).unwrap_or_else(|_| "null".to_string())); let secondary_bootstrap_json = secondary_aggregate.as_ref().map(|aggregate| { build_editor_bootstrap_json_with_ids( aggregate, &context, secondary_source_kind, secondary_root_uri, "__MNOTE_SECONDARY_PAGE_AGGREGATE__", "secondary", ) }); let panes_bootstrap_json = build_document_panes_bootstrap_json( &aggregate, &context, primary_source_kind, primary_root_uri, secondary_aggregate.as_ref(), secondary_source_kind, secondary_root_uri, secondary_requested, secondary_invalid, ); let body_content = crate::ssr::render_view(leptos::view! { }); let html = format!( r#" {} {} {} {} {} {} "#, escape_html(title), crate::ssr::MNOTE_CSS, escape_html(&document_id), secondary_requested, secondary_invalid, body_content, escape_script_json(&snapshot_json), escape_script_json(&bootstrap_json), escape_script_json(&panes_bootstrap_json), secondary_snapshot_json .as_ref() .map(|value| format!(r#""#, escape_script_json(value))) .unwrap_or_default(), secondary_bootstrap_json .as_ref() .map(|value| format!(r#""#, escape_script_json(value))) .unwrap_or_default(), render_document_title_controller_script(), render_editor_island_adapter_script(), ); let mut response = Html(html).into_response(); stamp_shell_headers(response.headers_mut(), "document"); stamp_recent_page_cookie(response.headers_mut(), &document_id); Ok(response) } pub(crate) fn build_editor_bootstrap_json( aggregate: &PageAggregate, context: &RequestContext, source_kind: Option<&str>, root_uri: Option<&str>, ) -> String { build_editor_bootstrap_json_with_ids( aggregate, context, source_kind, root_uri, "__MNOTE_PAGE_AGGREGATE__", "primary", ) } pub(crate) fn build_editor_bootstrap_json_with_ids( aggregate: &PageAggregate, context: &RequestContext, source_kind: Option<&str>, root_uri: Option<&str>, page_aggregate_script_id: &str, pane_role: &str, ) -> String { serde_json::to_string(&json!({ "schema": "mnote.editor_bootstrap.v1", "documentId": aggregate.identity.document_id, "workspaceId": aggregate.identity.workspace_id, "paneRole": pane_role, "sourceKind": source_kind .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("convex_workspace"), "rootUri": root_uri .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(""), "pageAggregateScriptId": page_aggregate_script_id, "saveEndpoint": "/api/documents/save", "titleEndpoint": "/api/documents/title", "editorHostKind": "leptos_tiptap_island", "assetMode": "rust-web-leptos-tiptap-spike-island-bundle", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, })) .unwrap_or_else(|_| "{}".to_string()) } fn build_document_panes_bootstrap_json( aggregate: &PageAggregate, context: &RequestContext, source_kind: Option<&str>, root_uri: Option<&str>, secondary_aggregate: Option<&PageAggregate>, secondary_source_kind: Option<&str>, secondary_root_uri: Option<&str>, secondary_requested: bool, secondary_invalid: bool, ) -> String { let primary = json!({ "role": "primary", "aggregate": aggregate, "bootstrap": serde_json::from_str::(&build_editor_bootstrap_json_with_ids( aggregate, context, source_kind, root_uri, "__MNOTE_PAGE_AGGREGATE__", "primary", )).unwrap_or_else(|_| json!({})), }); let secondary = secondary_aggregate.map(|aggregate| { json!({ "role": "secondary", "aggregate": aggregate, "bootstrap": serde_json::from_str::(&build_editor_bootstrap_json_with_ids( aggregate, context, secondary_source_kind, secondary_root_uri, "__MNOTE_SECONDARY_PAGE_AGGREGATE__", "secondary", )).unwrap_or_else(|_| json!({})), }) }); serde_json::to_string(&json!({ "schema": "mnote.document_panes_bootstrap.v1", "secondaryRequested": secondary_requested, "secondaryInvalid": secondary_invalid, "panes": match secondary { Some(secondary) => vec![primary, secondary], None => vec![primary], }, })) .unwrap_or_else(|_| "{}".to_string()) } fn normalize_optional_query_value(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } fn normalize_source_kind(value: Option<&str>) -> Option<&str> { normalize_optional_query_value(value) } fn normalize_optional_owned(value: Option<&str>) -> Option { normalize_optional_query_value(value).map(ToOwned::to_owned) } pub(crate) fn render_document_title_controller_script() -> &'static str { r#""# } pub(crate) fn render_editor_island_adapter_script() -> &'static str { r#""# } fn runtime_asset_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../spikes/leptos-tiptap-spike/generated/island") } fn resolve_runtime_asset_path(asset_path: &str) -> Option { let asset_path = asset_path.trim(); if asset_path.is_empty() || asset_path.starts_with('/') || asset_path.contains('\\') { return None; } let mut resolved = runtime_asset_root(); for component in FsPath::new(asset_path).components() { match component { Component::Normal(part) => resolved.push(part), _ => return None, } } Some(resolved) } fn runtime_asset_content_type(asset_path: &str) -> &'static str { if asset_path.ends_with(".wasm") { "application/wasm" } else if asset_path.ends_with(".js") { "application/javascript; charset=utf-8" } else if asset_path.ends_with(".json") { "application/json; charset=utf-8" } else { "application/octet-stream" } } pub async fn editor_image_placeholder_asset() -> Response { const SVG: &str = r##" E24 Image "##; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8") .header(header::CACHE_CONTROL, "no-store") .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(SVG)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn leptos_tiptap_manifest() -> Response { let manifest = json!({ "entryAssetPath": "mnote-leptos-tiptap-spike-island.js", "wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm", "assetPaths": [ "mnote-leptos-tiptap-spike-island.js", "mnote-leptos-tiptap-spike-island_bg.wasm" ], "generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island" }); let mut response = Json(manifest).into_response(); stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime"); response } pub async fn leptos_tiptap_asset(Path(asset_path): Path) -> Result { let Some(resolved) = resolve_runtime_asset_path(&asset_path) else { return Err(WebError::bad_request_code( "runtime_asset_path_invalid", "leptos-tiptap runtime asset 路径非法", )); }; let bytes = std::fs::read(&resolved).map_err(|_| { WebError::new( StatusCode::NOT_FOUND, "runtime_asset_not_found", format!("leptos-tiptap runtime asset 不存在: {asset_path}"), ) })?; let response = Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, runtime_asset_content_type(&asset_path), ) .header(header::CACHE_CONTROL, "no-store") .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(bytes)) .map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?; Ok(response) } pub async fn documents_page_compat( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { let document_id = query.document_id.trim().to_string(); if document_id.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(&context), ); } let aggregate = build_page_aggregate_snapshot( &state, &context, &document_id, query.workspace_id.as_deref(), None, None, ) .await?; let projection_owner = aggregate.source_label(); let mut response = ( StatusCode::OK, Json(json!({ "ok": true, "owner": "mnote-web", "schema": "mnote.documents_page_compat.v1", "page": aggregate, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, })), ) .into_response(); stamp_shell_headers(response.headers_mut(), "documents-page-compat"); if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") { if let Ok(value) = HeaderValue::from_str(projection_owner) { response.headers_mut().insert(name, value); } } Ok(response) } pub async fn page_aggregate( State(state): State, Extension(context): Extension, Path(document_id): Path, Query(query): Query, ) -> Result { let aggregate = build_page_aggregate_snapshot( &state, &context, &document_id, query.workspace_id.as_deref(), query.source_kind.as_deref(), query.root_uri.as_deref(), ) .await?; let projection_owner = aggregate.source_label(); let mut response = ( StatusCode::OK, Json(json!({ "ok": true, "owner": "mnote-web", "schema": "mnote.page_aggregate.v1", "result": aggregate, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, })), ) .into_response(); stamp_shell_headers(response.headers_mut(), "page-aggregate"); if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") { if let Ok(value) = HeaderValue::from_str(projection_owner) { response.headers_mut().insert(name, value); } } Ok(response) } pub(crate) async fn build_page_aggregate_snapshot( state: &AppState, context: &RequestContext, document_id: &str, workspace_id: Option<&str>, source_kind: Option<&str>, root_uri: Option<&str>, ) -> Result { if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") { let root_uri = root_uri .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; return resolve_local_markdown_page_aggregate(root_uri, document_id); } let meta = load_document_meta_result( state, context, DocumentMetaQuery { document_id: document_id.to_string(), workspace_id: workspace_id.map(str::to_string), }, ) .await?; let content = load_document_content_result( state, context, DocumentContentQuery { document_id: document_id.to_string(), workspace_id: workspace_id.map(str::to_string), }, ) .await?; let projection = execute_runtime_query_against_data( context, workspace_id, RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": document_id, "workspaceId": workspace_id, }), }, json!({ "meta": meta, "content": content, }), )?; serde_json::from_value::(projection).map_err(|error| { WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}")) }) } fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) { let value = document_id .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') { ch } else { '_' } }) .collect::(); if value.is_empty() { return; } let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax"); if let Ok(value) = HeaderValue::from_str(&cookie) { headers.append(header::SET_COOKIE, value); } } 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)); } headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } pub(crate) fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } pub(crate) fn escape_script_json(value: &str) -> String { value.replace(", default_workspace_name: &str, ) -> WorkspaceShellProjection { let spec = ProjectionSnapshotSpec { workspace_id, root_node_id: None, depth: Some(99), projection: KernelProjectionKind::SidebarTree, query: None, max_results: None, }; let dataset = load_projection_snapshot(config, context, &spec) .await .map(|snapshot| snapshot.dataset) .unwrap_or_else(|_| { let documents = active_document_id .map(|document_id| { json!([{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0, "is_starred": false }]) }) .unwrap_or_else(|| json!([])); json!({ "active_workspace_id": workspace_id, "active_page_id": active_document_id, "workspaces": [{ "id": workspace_id, "name": default_workspace_name }], "documents": documents }) }); build_workspace_shell_projection( &dataset, workspace_id, active_document_id, default_workspace_name, ) } /// 加载侧栏页面树 HTML(SSR) /// /// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。 /// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。 /// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。 pub(crate) async fn load_sidebar_tree_html( config: &AppConfig, context: &RequestContext, workspace_id: &str, active_document_id: Option<&str>, ) -> Option { let spec = ProjectionSnapshotSpec { workspace_id, root_node_id: None, depth: Some(99), projection: KernelProjectionKind::SidebarTree, query: None, max_results: None, }; let result = match load_projection_snapshot(config, context, &spec).await { Ok(snapshot) => Some(snapshot.projection), Err(_) if config.allow_dev_fixtures => { // Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。 let documents = active_document_id .map(|document_id| { serde_json::json!([ { "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 } ]) }) .unwrap_or_else(|| { serde_json::json!([ { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 }, { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }, { "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 } ]) }); let dev_dataset = serde_json::json!({ "active_workspace_id": workspace_id, "documents": documents, "trashed_documents": [], "media_assets": [], "trashed_media_assets": [], "mindmap_assets": [], "trashed_mindmap_assets": [], "table_assets": [], "trashed_table_assets": [], "mindmap_docs": [], "mindmap_asset_children": {} }); execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok() } Err(_) => None, }; result.map(|projection| { let rows = collect_page_tree_render_rows(&projection); render_initial_page_tree_html(&PageTreeInitialRenderInput { rows, active_node_id: active_document_id.map(ToOwned::to_owned), focused_node_id: None, }) }) } /// 加载文件树 HTML(SSR) /// /// 文件树与页面树共用同一份 sidebar dataset,再由 Rust kernel 输出 file_tree projection。 pub(crate) async fn load_file_tree_html( config: &AppConfig, context: &RequestContext, workspace_id: &str, active_document_id: Option<&str>, ) -> Option { let spec = ProjectionSnapshotSpec { workspace_id, root_node_id: None, depth: Some(99), projection: KernelProjectionKind::FileTree, query: None, max_results: None, }; let result = match load_projection_snapshot(config, context, &spec).await { Ok(snapshot) => Some(snapshot.projection), Err(_) if config.allow_dev_fixtures => { let documents = active_document_id .map(|document_id| { serde_json::json!([ { "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 } ]) }) .unwrap_or_else(|| { serde_json::json!([ { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 }, { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 } ]) }); let dev_dataset = serde_json::json!({ "active_workspace_id": workspace_id, "documents": documents, "trashed_documents": [], "media_assets": [], "trashed_media_assets": [], "mindmap_assets": [], "trashed_mindmap_assets": [], "table_assets": [], "trashed_table_assets": [], "mindmap_docs": [], "mindmap_asset_children": {} }); execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok() } Err(_) => None, }; result.map(|projection| { let rows = collect_filetree_render_rows(&projection, active_document_id); render_initial_filetree_html(&FileTreeInitialRenderInput { rows }) }) } pub(crate) fn render_local_sidebar_tree_html( root_uri: &str, active_document_id: Option<&str>, ) -> Result { let snapshot = load_local_folder_page_tree_snapshot(root_uri)?; let rows = collect_page_tree_render_rows(&snapshot.projection); Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput { rows, active_node_id: active_document_id.map(ToOwned::to_owned), focused_node_id: None, })) } pub(crate) fn render_local_file_tree_html( root_uri: &str, active_document_id: Option<&str>, ) -> Result { let snapshot = load_local_folder_file_tree_snapshot(root_uri)?; let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id); Ok(render_initial_filetree_html(&FileTreeInitialRenderInput { rows, })) } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{header, Request, StatusCode}; use serde_json::Value; 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, 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( r#"{ "documents:getMeta": { "id": "doc_1", "workspace_id": "ws_demo", "title": "服务端页面", "updated_at": "2026-04-18T09:30:00Z", "can_edit": true, "word_count": 42, "character_count": 128, "block_count": 3 }, "documents:getContent": { "content": [{"id": "block_1", "type": "paragraph", "content": []}], "revision": 7, "conflict_detection_key": "doc_1:7", "pageSubtree": {"rootNodeId": "doc_1", "outline": []} } }"# .into(), ), 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 document_shell_returns_page_aggregate_snapshot() { let response = app() .oneshot( Request::builder() .uri("/documents/doc_1?workspaceId=ws_demo") .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("document") ); assert!(response .headers() .get_all("set-cookie") .iter() .any(|value| value .to_str() .unwrap_or_default() .contains("mnote_recent_page_id=doc_1"))); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("data-testid=\"wolai-sidebar\"")); assert!(html.contains("data-testid=\"wolai-topbar\"")); assert!(html.contains("data-testid=\"wolai-floating-ai\"")); assert!(html.contains("星标置顶")); assert!(html.contains("我的页面")); assert!(html.contains("垃圾箱")); assert!(html.contains("模板中心")); assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\"")); assert!(html.contains("data-testid=\"mnote-page-subtree\"")); assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\"")); assert!(html.contains("data-editor-host=\"leptos_tiptap_island\"")); assert!(html.contains("aria-label=\"页面标题\"")); assert!(html.contains("data-page-title-input=\"true\"")); assert!(html.contains("data-title-endpoint=\"/api/documents/title\"")); assert!(html.contains("mnote.document_title_controller.v1")); assert!(html.contains( r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"# )); assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title")); assert!(html.contains("data-testid=\"wolai-page-settings-trigger\"")); assert!(html.contains("data-mnote-action=\"open-page-settings\"")); assert!(html.contains("data-mnote-action=\"open-page-ai\"")); assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\"")); assert!(html.contains("data-testid=\"mnote-document-workspace\"")); assert!(html.contains("data-document-pane=\"true\"")); assert!(html.contains("data-pane-role=\"primary\"")); assert!(html.contains("data-document-pane-resizer=\"true\"")); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__")); assert!(html.contains("mnote.tree_live_bootstrap.v1")); assert!(html.contains("/api/tree/events")); assert!(html.contains("data-mnote-tree-live-transport")); assert!(!html.contains("mnote-web-document-shell")); } #[tokio::test] async fn page_aggregate_endpoint_returns_snapshot_contract() { let response = app() .oneshot( Request::builder() .uri("/api/page-aggregate/doc_1?workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-page-aggregate-owner") .and_then(|value| value.to_str().ok()), Some("rust-kernel") ); assert_eq!( response .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), Some("no-store") ); 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["owner"], "mnote-web"); assert_eq!(payload["schema"], "mnote.page_aggregate.v1"); assert_eq!(payload["result"]["source"], "KernelProjection"); assert_eq!(payload["result"]["projectionVersion"], 1); assert_eq!(payload["result"]["identity"]["documentId"], "doc_1"); assert_eq!(payload["result"]["body"]["revision"], 7); } #[tokio::test] async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() { let root = std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); std::fs::write( root.join("README.md"), "---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n", ) .expect("write local md"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .uri(format!( "/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}" )) .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), Some("no-store") ); 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["schema"], "mnote.page_aggregate.v1"); assert_eq!( payload["result"]["identity"]["documentId"], "local-md:README.md" ); assert_eq!(payload["result"]["head"]["title"], "Local Aggregate"); assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false); assert_eq!(payload["result"]["body"]["revision"], 0); assert!(payload["result"]["body"]["content"] .to_string() .contains("Local Heading")); } #[tokio::test] async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() { let root = std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs")).expect("create local docs"); std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md"); std::fs::write(root.join("docs").join("child.md"), "# Child Page\n") .expect("write child md"); std::fs::write(root.join("asset.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree" )) .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), Some("no-store") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("Local Shell")); assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\"")); assert!(html.contains("Child Page")); assert!(html.contains("asset.png")); assert!(html.contains("data-row-kind=\"markdown\"")); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); assert!(html.contains("refreshSessionFromExternalFileChange")); assert!(html.contains("/api/local-folder/events")); assert!(html.contains("new EventSource(url.toString())")); assert!(html.contains("localFolderEventRegistry")); assert!(html.contains("command: 'replaceContent'")); assert!(html.contains("external-change-conflict")); assert!(!html.contains( "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" )); } #[tokio::test] async fn document_shell_renders_local_markdown_attachment_name_in_html() { let root = std::env::temp_dir().join(format!( "mnote-local-document-shell-media-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs")).expect("create local docs"); std::fs::write( root.join("docs").join("blocks.md"), "---\ntitle: Complex Title\n---\n[Spec](assets/spec.pdf)\n", ) .expect("write local md"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}" )) .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("Spec")); assert!(html.contains("assets/spec.pdf")); } #[tokio::test] async fn document_shell_renders_secondary_pane_contract_when_query_present() { let response = app() .oneshot( Request::builder() .uri("/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1") .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 html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("data-has-secondary-pane=\"true\"")); assert!(html.contains("data-pane-role=\"secondary\"")); assert!(html.contains("data-mnote-pane-close=\"secondary\"")); assert!(html.contains("__MNOTE_SECONDARY_PAGE_AGGREGATE__")); assert!(html.contains("__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__")); assert!(html.contains("\"paneRole\":\"secondary\"")); assert!(html.contains("\"secondaryRequested\":true")); assert!(html.contains("\"secondaryInvalid\":false")); } #[tokio::test] async fn document_shell_bootstrap_preserves_inline_mark_conversion() { let response = app() .oneshot( Request::builder() .uri("/documents/doc_1?workspaceId=ws_demo") .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 html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("legacyInlineContentToTiptap")); assert!(html.contains("legacyStylesToTiptapMarks")); assert!(html.contains("legacyMarkArrayToTiptapMarks")); assert!(html.contains("firstNonEmptyText(block?.props?.sourcePath")); assert!(html.contains("marks.push({ type: 'code' })")); assert!(html.contains("marks.push({ type: 'link', attrs: { href } })")); assert!(html.contains("styles.link = href")); assert!(html.contains("contentNodes.map((node) => {")); assert!(!html.contains( "block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')" )); } }