use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::document_buffer_store::{self}; 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::gateway::default_workspace_name_for_context; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot_with_reveal, load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot_with_reveal, load_local_folder_page_tree_snapshot_with_reveal, 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_filetree_pending_shell_html, 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_page_breadcrumb_html, 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, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use control_plane::UpsertNavigationRecentInput; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::{json, Value}; use std::fs; 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"; #[cfg(test)] static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentShellQuery { pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub tree_view: Option, pub file_tree_scope: Option, pub secondary_document_id: Option, pub secondary_source_kind: Option, pub secondary_root_uri: Option, } pub async fn document_page_shell( State(state): State, Extension(context): Extension, uri: Uri, Path(document_id): Path, Query(query): Query, ) -> Result { if crate::routes::gateway::current_actor_id(&state, &context).is_none() { return redirect_response(&format!( "/auth?next={}", query_escape( uri.path_and_query() .map(|value| value.as_str()) .unwrap_or("/") ) )); } let mut primary_source_kind = normalize_source_kind(query.source_kind.as_deref()); let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref()); if primary_source_kind.is_none() && document_id.trim().starts_with("local-md:") && primary_root_uri.is_some() { primary_source_kind = Some("local_folder"); } if primary_source_kind.is_none() && document_id.trim().starts_with("local-md:") { return redirect_response(&format!( "/?routeGuard={}&missingPage={}", query_escape("local_folder_source_required"), query_escape(&document_id), )); } let aggregate = match build_page_aggregate_snapshot( &state, &context, &document_id, query.workspace_id.as_deref(), primary_source_kind, primary_root_uri, ) .await { Ok(aggregate) => aggregate, Err(error) if primary_source_kind == Some("local_folder") && error.code() == "local_markdown_not_found" => { return redirect_response(&local_folder_navigation_redirect_location( primary_root_uri.unwrap_or_default(), query.file_tree_scope.as_deref(), &document_id, )); } Err(error) if primary_source_kind == Some("local_folder") && error.code() == "local_folder_root_required" => { return redirect_response(&format!( "/?routeGuard={}&missingPage={}", query_escape("local_folder_root_required"), query_escape(&document_id), )); } Err(error) if primary_source_kind == Some("local_folder") && error.code() == "local_workspace_access_denied" => { return redirect_response(&format!( "/?routeGuard={}&missingPage={}", query_escape("local_workspace_access_denied"), query_escape(&document_id), )); } Err(error) => return Err(error), }; // 初始化 BufferStore:local_folder 文档打开时记录 file_version if primary_source_kind == Some("local_folder") { if let Some(root_uri) = primary_root_uri { let relative_path = local_markdown_relative_path_from_document_id(&document_id); let file_version = aggregate.body.file_version.as_str().map(|s| s.to_string()); let ws_path = document_buffer_store::build_local_folder_workspace_path( &aggregate.identity.workspace_id, root_uri, &relative_path, &document_id, ); state.buffer_store.init_buffer(&ws_path, file_version, None); record_navigation_recent_page( &state, &context, root_uri, &relative_path, &document_id, aggregate.head.title.as_str(), Some(&aggregate.identity.workspace_id), ); } } 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 = default_workspace_name_for_context(&state, &context); let mut workspace_projection = load_workspace_shell_projection( Some(&state), 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 requests_filetree_first = query .tree_view .as_deref() .map(str::trim) .is_some_and(|value| value == "filetree"); let file_tree_scope = query .file_tree_scope .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); 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_scoped(root_uri, Some(&document_id), file_tree_scope) .unwrap_or_default(), render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope) .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), None, ) .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()), if requests_filetree_first { Some("filetree") } else { None }, file_tree_scope, ); let workspace_name = workspace_projection.workspace_name.clone(); let breadcrumb_html = render_page_breadcrumb_html(&workspace_projection, Some(&document_id)); 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 pi_lab_loader_script = render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab); let vault_nav_href = crate::routes::gateway::vault_nav_href_for_context( primary_source_kind, primary_root_uri, ); let body_content = crate::ssr::render_view(leptos::view! { }); let hermes_settings_config_script = render_hermes_settings_config_script(); let html = format!( r#" {} {} {} {} {} {} {} {} {} {} "#, escape_html(title), render_editor_runtime_preload_links(), crate::ssr::MNOTE_CSS, escape_html(&document_id), escape_html(primary_source_kind.unwrap_or("local_folder")), escape_html(primary_root_uri.unwrap_or("")), secondary_requested, secondary_invalid, body_content, escape_script_json(&snapshot_json), escape_script_json(&bootstrap_json), escape_script_json(&panes_bootstrap_json), hermes_settings_config_script, 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(), format!( r#""#, mnote_browser_runtime_src("document-conflict-panel-runtime.js") ), pi_lab_loader_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) } fn redirect_response(location: &str) -> Result { Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, location) .body(Body::empty()) .map_err(|error| WebError::internal(format!("跳转响应构造失败: {error}"))) } fn local_folder_navigation_redirect_location( root_uri: &str, file_tree_scope: Option<&str>, missing_document_id: &str, ) -> String { let scope = file_tree_scope .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| local_markdown_parent_scope_from_document_id(missing_document_id)); let mut location = format!( "/?sourceKind=local_folder&rootUri={}&treeView=filetree", query_escape(root_uri) ); if !scope.trim().is_empty() { location.push_str("&fileTreeScope="); location.push_str(&query_escape(&scope)); } location.push_str("&missingPage="); location.push_str(&query_escape(missing_document_id)); location } fn local_markdown_parent_scope_from_document_id(document_id: &str) -> String { let relative_path = local_markdown_relative_path_from_document_id(document_id); relative_path .rsplit_once('/') .map(|(parent, _)| parent.to_string()) .unwrap_or_default() } fn local_markdown_relative_path_from_document_id(document_id: &str) -> String { document_id .trim() .strip_prefix("local-md:") .unwrap_or_default() .replace("~2F", "/") } fn record_navigation_recent_page( state: &AppState, context: &RequestContext, root_uri: &str, relative_path: &str, document_id: &str, title: &str, workspace_id: Option<&str>, ) { let Some(actor_id) = crate::routes::gateway::current_actor_id(state, context) else { return; }; if actor_id.trim().is_empty() || actor_id.trim() == "anonymous" || root_uri.trim().is_empty() || document_id.trim().is_empty() { return; } let fallback_title = relative_path .rsplit('/') .next() .filter(|value| !value.trim().is_empty()) .unwrap_or("未命名页面"); let _ = state .control_plane() .upsert_navigation_recent(UpsertNavigationRecentInput { id: None, user_id: actor_id, workspace_id: workspace_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), kind: "page".to_string(), source_kind: "local_folder".to_string(), root_uri: root_uri.trim().to_string(), relative_path: Some(relative_path.trim().trim_matches('/').to_string()) .filter(|value| !value.is_empty()), document_id: Some(document_id.trim().to_string()), title: title .trim() .is_empty() .then(|| fallback_title.to_string()) .unwrap_or_else(|| title.trim().to_string()), metadata_json: "{}".to_string(), }); } fn query_escape(value: &str) -> String { value .bytes() .flat_map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => { vec![byte as char] } _ => format!("%{byte:02X}").chars().collect::>(), }) .collect() } 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", ) } fn render_hermes_settings_config_script() -> String { let Some(base_url) = [ "MNOTE_WEB_HERMES_UPSTREAM_URL", "MNOTE_HERMES_UPSTREAM_URL", "MNOTE_HERMES_API_BASE_URL", ] .into_iter() .find_map(env_or_dotenv) .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()) else { return String::new(); }; let settings_url = format!("{base_url}/hermes/settings"); let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string()); format!( r#""#, escape_script_json(&encoded) ) } fn env_or_dotenv(key: &str) -> Option { if let Ok(value) = std::env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } if cfg!(test) { return None; } let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join(".env.all"); let content = fs::read_to_string(root).ok()?; for line in content.lines() { let line = line.trim_end_matches('\r'); if line.starts_with('#') || line.trim().is_empty() { continue; } let Some((candidate_key, value)) = line.split_once('=') else { continue; }; if candidate_key.trim() != key { continue; } let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } None } 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 { let normalized_source_kind = source_kind .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("local_folder"); let save_endpoint = if normalized_source_kind == "local_folder" { "/api/page-body/write" } else { "/api/documents/save" }; serde_json::to_string(&json!({ "schema": "mnote.editor_bootstrap.v1", "documentId": aggregate.identity.document_id, "workspaceId": aggregate.identity.workspace_id, "paneRole": pane_role, "sourceKind": normalized_source_kind, "rootUri": root_uri .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(""), "pageAggregateScriptId": page_aggregate_script_id, "saveEndpoint": save_endpoint, "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()) } pub(crate) 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 mnote_browser_runtime_src(asset: &str) -> String { let base = format!("/api/mnote-browser-runtime/{asset}"); if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() { format!("{base}?devHot={cache_buster}") } else { base } } pub(crate) fn render_editor_island_adapter_script() -> String { format!( r#""#, mnote_browser_runtime_src("document-editor-adapter-runtime.js") ) } pub(crate) fn render_page_ai_pi_lab_loader_script(enabled: bool) -> String { if !enabled { return String::new(); } format!( r#""#, mnote_browser_runtime_src("sidebar-page-ai-pi-lab-runtime.js") ) } fn leptos_tiptap_runtime_src(asset: &str) -> String { let base = format!("/api/leptos-tiptap-runtime/{asset}"); if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() { format!("{base}?devHot={cache_buster}") } else { base } } pub(crate) fn render_editor_runtime_preload_links() -> String { format!( r#" "#, leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island.js"), leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island_bg.wasm") ) } 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 resolve_lazy_runtime_asset_path(asset_path: &str) -> Option { let asset_path = asset_path.trim(); if asset_path != "tiptap_mindmap_paragraph_runtime.js" { return None; } Some( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../../reference-code/leptos-tiptap/src/js/generated") .join(asset_path), ) } 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" } } fn runtime_asset_cache_control() -> &'static str { if crate::routes::dev_hot::dev_hot_reload_enabled() { "no-store" } else { "public, max-age=3600, stale-while-revalidate=86400" } } fn browser_runtime_js_body(js: &'static str) -> Body { let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else { return Body::from(js); }; Body::from(rewrite_browser_runtime_imports_for_dev_hot( js, cache_buster, )) } fn rewrite_browser_runtime_imports_for_dev_hot(js: &str, cache_buster: &str) -> String { let suffix = format!("?devHot={cache_buster}"); js.replace(".js';", &format!(".js{suffix}';")) .replace(".js\";", &format!(".js{suffix}\";")) .replace(".js');", &format!(".js{suffix}');")) .replace(".js\");", &format!(".js{suffix}\");")) .replace(".js'\n", &format!(".js{suffix}'\n")) .replace(".js\"\n", &format!(".js{suffix}\"\n")) } fn runtime_asset_body(asset_path: &str, bytes: Vec) -> Body { let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else { return Body::from(bytes); }; if !asset_path.ends_with(".js") { return Body::from(bytes); } match String::from_utf8(bytes) { Ok(js) => Body::from(rewrite_browser_runtime_imports_for_dev_hot( &js, cache_buster, )), Err(error) => Body::from(error.into_bytes()), } } 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())) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PdfPreviewQuery { #[serde(default, alias = "fileUrl")] file_url: Option, #[serde(default, alias = "fileName")] file_name: Option, #[serde(default)] page: Option, #[serde(default)] bbox: Option, #[serde(default, alias = "blockId")] block_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OfficePreviewQuery { file_url: Option, file_name: Option, file_type: Option, workspace_id: Option, source_kind: Option, root_uri: Option, document_id: Option, #[serde(default)] page: Option, #[serde(default)] bbox: Option, #[serde(default, alias = "sourceMapPath")] source_map_path: Option, #[serde(default, alias = "blockId")] block_id: Option, #[serde(default, alias = "paragraphOrdinal")] paragraph_ordinal: Option, #[serde(default, alias = "paraIdStart")] para_id_start: Option, #[serde(default, alias = "paraIdEnd")] para_id_end: Option, #[serde(default, alias = "textFingerprint")] text_fingerprint: Option, #[serde(default, alias = "evidenceText")] evidence_text: Option, #[serde(default, alias = "searchQuery")] search_query: Option, } pub async fn office_preview_page(Query(query): Query) -> Response { let file_url = query.file_url.unwrap_or_default(); let file_name = query .file_name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("Office 预览"); let file_type = query .file_type .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or_else(|| { file_name .rsplit_once('.') .map(|(_, ext)| ext) .unwrap_or("docx") }) .to_ascii_lowercase(); let page_width_content_type = office_preview_page_width_content_type(&file_type); let workspace_id = query.workspace_id.unwrap_or_default(); let source_kind = query.source_kind.unwrap_or_default(); let root_uri = query.root_uri.unwrap_or_default(); let document_id = query.document_id.unwrap_or_default(); let target_page = query .page .map(|value| value.to_string()) .unwrap_or_default(); let target_bbox = query.bbox.unwrap_or_default(); let target_source_map_path = query.source_map_path.unwrap_or_default(); let target_block_id = query.block_id.unwrap_or_default(); let target_paragraph_ordinal = query.paragraph_ordinal.unwrap_or_default(); let target_para_id_start = query.para_id_start.unwrap_or_default(); let target_para_id_end = query.para_id_end.unwrap_or_default(); let target_text_fingerprint = query.text_fingerprint.unwrap_or_default(); let target_evidence_text = query.evidence_text.unwrap_or_default(); let target_search_query = query.search_query.unwrap_or_default(); let html = format!( r#" {title}
"#, title = escape_html(file_name), file_url = escape_html(&file_url), file_name = escape_html(file_name), file_type = escape_html(&file_type), page_width_content_type = escape_html(page_width_content_type), workspace_id = escape_html(&workspace_id), source_kind = escape_html(&source_kind), root_uri = escape_html(&root_uri), document_id = escape_html(&document_id), target_page = escape_html(&target_page), target_bbox = escape_html(&target_bbox), target_source_map_path = escape_html(&target_source_map_path), target_block_id = escape_html(&target_block_id), target_paragraph_ordinal = escape_html(&target_paragraph_ordinal), target_para_id_start = escape_html(&target_para_id_start), target_para_id_end = escape_html(&target_para_id_end), target_text_fingerprint = escape_html(&target_text_fingerprint), target_evidence_text = escape_html(&target_evidence_text), ); let mut response = Html(html).into_response(); stamp_shell_headers(response.headers_mut(), "office-preview"); response } fn office_preview_page_width_content_type(file_type: &str) -> &'static str { match file_type.trim().to_ascii_lowercase().as_str() { "xls" | "xlsx" | "ods" | "csv" => "excel", "ppt" | "pptx" | "odp" => "ppt", _ => "word", } } pub async fn office_preview_vendor_asset( Path(asset_path): Path, ) -> Result { let asset_path = asset_path.trim(); let relative = match asset_path { "jszip.min.js" => "node_modules/jszip/dist/jszip.min.js", "docx-preview.min.js" => "node_modules/docx-preview/dist/docx-preview.min.js", "xlsx.full.min.js" => "node_modules/@e965/xlsx/dist/xlsx.full.min.js", "pptx-preview.umd.js" => "node_modules/pptx-preview/dist/pptx-preview.umd.js", _ => { return Err(WebError::new( StatusCode::NOT_FOUND, "office_preview_vendor_asset_not_found", format!("Office preview vendor asset 不存在: {asset_path}"), )); } }; let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join(relative); let bytes = std::fs::read(&path).map_err(|error| { WebError::new( StatusCode::SERVICE_UNAVAILABLE, "office_preview_vendor_asset_unavailable", format!("Office preview vendor asset 不可用,请先运行 npm install: {error}"), ) })?; Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(bytes)) .map_err(|error| { WebError::internal(format!("Office preview vendor asset 响应构造失败: {error}")) }) } pub async fn pdf_preview_page(Query(query): Query) -> Response { let file_url = query.file_url.unwrap_or_default(); let file_name = query .file_name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("PDF 预览"); let target_page = query.page.unwrap_or_default(); let target_bbox = query.bbox.unwrap_or_default(); let target_block_id = query.block_id.unwrap_or_default(); let html = format!( r#" {title}
"#, title = escape_html(file_name), file_url = escape_html(&file_url), file_name = escape_html(file_name), target_page = target_page, target_bbox = escape_html(&target_bbox), target_block_id = escape_html(&target_block_id), ); let mut response = Html(html).into_response(); stamp_shell_headers(response.headers_mut(), "pdf-preview"); response } pub async fn pdfjs_asset(Path(asset_path): Path) -> Result { let asset_path = asset_path.trim(); let relative = match asset_path { "pdf.mjs" => "build/pdf.mjs", "pdf.worker.mjs" => "build/pdf.worker.mjs", _ => { return Err(WebError::new( StatusCode::NOT_FOUND, "pdfjs_asset_not_found", format!("PDF.js asset 不存在: {asset_path}"), )); } }; let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join("node_modules/pdfjs-dist") .join(relative); let bytes = std::fs::read(&path).map_err(|error| { WebError::new( StatusCode::SERVICE_UNAVAILABLE, "pdfjs_asset_unavailable", format!("PDF.js asset 不可用,请先运行 npm install: {error}"), ) })?; Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(bytes)) .map_err(|error| WebError::internal(format!("PDF.js asset 响应构造失败: {error}"))) } pub async fn resource_open_runtime_asset() -> Response { // include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/) const JS: &str = include_str!("../../browser/resource-open-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn mnote_ui_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/mnote-ui-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn local_upload_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/local-upload-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_tree_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-tree-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn agent_stream_event_router_asset() -> Response { const JS: &str = include_str!("../../browser/agent-stream-event-router.js"); Response::builder() .header("content-type", "application/javascript; charset=utf-8") .header("cache-control", "public, max-age=3600") .body(Body::from(JS)) .expect("agent-stream-event-router.js") } pub async fn sidebar_page_ai_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_render_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_permission_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_profile_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_session_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_skill_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_target_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_ai_pi_lab_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_settings_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_shell_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-shell-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_workspace_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-workspace-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_page_tree_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-page-tree-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_tree_live_apply_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-tree-live-apply-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_filetree_open_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-filetree-open-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_filetree_command_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-filetree-command-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_filetree_upload_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-filetree-upload-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn sidebar_attachment_open_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-attachment-open-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn filetree_keyboard_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/filetree-keyboard-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn filetree_dnd_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/filetree-dnd-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn filetree_context_menu_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/filetree-context-menu-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn filetree_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/filetree-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn filetree_selection_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/filetree-selection-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn local_folder_event_bus_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/local-folder-event-bus-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_live_controller_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-live-controller.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_render_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-render-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_page_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-page-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_state_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-state-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_icons_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-icons-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_filetree_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-filetree-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_filetree_menu_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-filetree-menu-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-filetree-dnd-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_picker_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn tree_shell_dom_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-dom-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_conflict_panel_runtime_asset() -> Response { // include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/) const JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_pane_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-pane-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_mindmap_host_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-mindmap-host-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_resource_tab_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-resource-tab-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_session_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-session-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_slash_position_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-slash-position-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_tiptap_conversion_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-tiptap-conversion-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn document_editor_adapter_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-editor-adapter-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } pub async fn vault_workbench_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/vault-workbench-runtime.js"); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(browser_runtime_js_body(JS)) .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", "tiptap_mindmap_paragraph_runtime.js" ], "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.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(runtime_asset_cache_control()), ); 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 = match std::fs::read(&resolved) { Ok(bytes) => bytes, Err(_) => { let Some(lazy_resolved) = resolve_lazy_runtime_asset_path(&asset_path) else { return Err(WebError::new( StatusCode::NOT_FOUND, "runtime_asset_not_found", format!("leptos-tiptap runtime asset 不存在: {asset_path}"), )); }; std::fs::read(&lazy_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, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(runtime_asset_body(&asset_path, bytes)) .map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?; 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") })?; ensure_local_workspace_read_access_with_state(state, context, root_uri) .map_err(|error| error.with_context(context))?; let root_uri_for_build = root_uri.to_string(); let root_uri_for_preferences = root_uri_for_build.clone(); let document_id = document_id.to_string(); let mut aggregate = tokio::task::spawn_blocking(move || { #[cfg(test)] block_local_page_aggregate_for_test(); resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id) }) .await .map_err(|error| { WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")) })??; annotate_local_attachment_refs_authorization(state, context, &mut aggregate)?; super::ui_preferences::apply_effective_page_preferences( state, context, &mut aggregate, source_kind, Some(root_uri_for_preferences.as_str()), )?; return Ok(aggregate); } 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 annotate_local_attachment_refs_authorization( state: &AppState, context: &RequestContext, aggregate: &mut PageAggregate, ) -> Result<(), WebError> { let Some(refs) = aggregate.body.attachment_refs.as_array_mut() else { return Ok(()); }; for item in refs { let Some(object) = item.as_object_mut() else { continue; }; let resolved_uri = object .get("resolvedUri") .and_then(Value::as_str) .map(str::trim) .filter(|value| value.starts_with("file://")); let Some(resolved_uri) = resolved_uri else { continue; }; let kind = object .get("kind") .and_then(Value::as_str) .unwrap_or_default(); let owner_root_uri = object .get("ownerRootUri") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if kind == "pageLocal" && !owner_root_uri.is_empty() && (resolved_uri == owner_root_uri || resolved_uri .strip_prefix(owner_root_uri) .is_some_and(|suffix| suffix.starts_with('/'))) { object.insert("authorized".to_string(), Value::Bool(true)); continue; } let access = state .control_plane() .resolve_access(&context.auth.actor_id, resolved_uri) .map_err(|error| { WebError::internal(format!("control-plane 附件授权解析失败: {error}")) })?; object.insert( "authorized".to_string(), Value::Bool(access.permission != "none"), ); } Ok(()) } 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(", config: &AppConfig, context: &RequestContext, workspace_id: &str, active_document_id: Option<&str>, 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 mut dataset = match load_projection_snapshot(config, context, &spec).await { Ok(snapshot) => snapshot.dataset, Err(_) if config.allow_dev_fixtures => { 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, "dev_fixture": true }) } Err(_) => json!({ "active_workspace_id": workspace_id, "active_page_id": active_document_id, "workspaces": [{ "id": workspace_id, "name": default_workspace_name }], "documents": [], "degraded": true, "degraded_reason": "projection_unavailable" }), }; if let Some(state) = state { attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset); } build_workspace_shell_projection( &dataset, workspace_id, active_document_id, default_workspace_name, ) } pub(crate) fn attach_sidebar_shortcuts_to_dataset( state: &AppState, context: &RequestContext, workspace_id: &str, dataset: &mut Value, ) { let shortcuts = crate::routes::sidebar_shortcuts::load_sidebar_shortcut_dataset( state, context, workspace_id, ); if shortcuts.is_empty() { return; } if let Some(object) = dataset.as_object_mut() { object.insert("sidebarShortcuts".to_string(), Value::Array(shortcuts)); } } /// 加载侧栏页面树 HTML(SSR) /// /// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。 /// 如果加载失败(如 cloud/compat source 不可用),返回空字符串,侧栏静默降级为无树状态。 /// 当 allow_dev_fixtures 启用且 cloud/compat source 不可用时,使用内建示例数据展示页面树。 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, false)), 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() .map(|projection| (projection, true)) } Err(_) => None, }; result.map(|(projection, dev_fixture)| { let rows = collect_page_tree_render_rows(&projection); let html = render_initial_page_tree_html(&PageTreeInitialRenderInput { rows, active_node_id: active_document_id.map(ToOwned::to_owned), focused_node_id: None, }); mark_dev_fixture_html(html, dev_fixture, "sidebar-tree") }) } /// 加载文件树 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>, active_row_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, false)), 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() .map(|projection| (projection, true)) } Err(_) => None, }; result.map(|(projection, dev_fixture)| { let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id); let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows }); mark_dev_fixture_html(html, dev_fixture, "file-tree") }) } fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String { if !dev_fixture { return html; } format!( r#"{html}"# ) } pub(crate) fn render_local_sidebar_tree_html( root_uri: &str, active_document_id: Option<&str>, ) -> Result { render_local_sidebar_tree_html_scoped(root_uri, active_document_id, None) } pub(crate) fn render_local_sidebar_tree_html_scoped( root_uri: &str, active_document_id: Option<&str>, file_tree_scope: Option<&str>, ) -> Result { let snapshot = if let Some(scope) = file_tree_scope .map(str::trim) .filter(|value| !value.is_empty()) { load_local_folder_page_tree_scope_snapshot_with_reveal( root_uri, scope, active_document_id, )? } else { load_local_folder_page_tree_snapshot_with_reveal(root_uri, active_document_id)? }; Ok(render_local_sidebar_tree_html_from_snapshot( &snapshot, active_document_id, )) } pub(crate) fn render_local_sidebar_tree_html_from_snapshot( snapshot: &crate::routes::snapshot_support::ProjectionSnapshot, active_document_id: Option<&str>, ) -> String { let rows = collect_page_tree_render_rows(&snapshot.projection); 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>, active_row_id: Option<&str>, ) -> Result { render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None) } /// Fast path for home SSR: keep FileTree shell structure without scanning the folder. /// Browser hydrates rows after first paint when `data-filetree-ssr="pending"`. pub(crate) fn render_local_file_tree_pending_shell_html() -> String { render_filetree_pending_shell_html() } pub(crate) fn render_local_file_tree_html_scoped( root_uri: &str, active_document_id: Option<&str>, active_row_id: Option<&str>, file_tree_scope: Option<&str>, ) -> Result { let snapshot = if let Some(scope) = file_tree_scope .map(str::trim) .filter(|value| !value.is_empty()) { load_local_folder_file_tree_children_snapshot_with_reveal( root_uri, scope, active_document_id, )? } else { load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)? }; let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id); Ok(render_initial_filetree_html(&FileTreeInitialRenderInput { rows, })) } #[cfg(test)] fn block_local_page_aggregate_for_test() { let delay_ms = LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst); if delay_ms > 0 { std::thread::sleep(std::time::Duration::from_millis(delay_ms)); } } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; use axum::body::{to_bytes, Body}; use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput}; use serde_json::Value; use std::time::{Duration, Instant}; use tower::util::ServiceExt; const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str = include_str!("../../browser/document-editor-adapter-runtime.js"); const DOCUMENT_MINDMAP_HOST_RUNTIME_JS: &str = include_str!("../../browser/document-mindmap-host-runtime.js"); const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js"); const DOCUMENT_RESOURCE_TAB_RUNTIME_JS: &str = include_str!("../../browser/document-resource-tab-runtime.js"); const DOCUMENT_SESSION_RUNTIME_JS: &str = include_str!("../../browser/document-session-runtime.js"); const DOCUMENT_SLASH_POSITION_RUNTIME_JS: &str = include_str!("../../browser/document-slash-position-runtime.js"); const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js"); const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../browser/sidebar-tree-runtime.js"); const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str = include_str!("../../browser/document-tiptap-conversion-runtime.js"); 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( 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": []}], "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["editor_1"], "blocks": [{ "blockId": "editor_1", "blockType": "paragraph", "contentNodes": [{ "payload": {"type": "text", "text": "来自 editorDocument 的正文"}, "attrs": {} }], "childBlockIds": [] }] }, "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(), })) .layer(axum::middleware::from_fn(inject_test_actor)) } fn app_without_test_actor() -> 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( 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(), })) } fn temp_root(name: &str) -> std::path::PathBuf { let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create temp root"); root } async fn inject_test_actor( mut request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { request .headers_mut() .entry("x-mnote-actor-id") .or_insert(HeaderValue::from_static("user_test")); request .headers_mut() .entry("x-mnote-actor-type") .or_insert(HeaderValue::from_static("user")); next.run(request).await } fn init_local_workspace(root: &std::path::Path, actor_id: &str) { crate::routes::local_folder_source::initialize_local_workspace_for_actor( actor_id, &format!("file://{}", root.display()), ) .expect("init local workspace"); } fn request_context(actor_id: &str, actor_type: &str) -> RequestContext { let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", actor_id.parse().unwrap()); headers.insert("x-mnote-actor-type", actor_type.parse().unwrap()); RequestContext::from_http_parts( &Method::GET, &"/api/page-aggregate/local-md:test.md".parse().expect("uri"), &headers, ) } fn grant_local_workspace_read_access( state: &AppState, actor_id: &str, root_uri: &str, root: &std::path::Path, ) { state .control_plane() .upsert_user(UpsertUserInput { id: Some(actor_id.into()), email: Some(format!("{actor_id}@example.com")), username: actor_id.into(), display_name: actor_id.into(), role: None, password_hash: None, }) .expect("upsert control-plane reader"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: actor_id.into(), workspace_id: None, root_uri: root_uri.into(), root_path: root .canonicalize() .expect("canonical root") .display() .to_string(), permission: "read".into(), recursive: true, capabilities: vec![], source: "unit-test".into(), created_by: None, }) .expect("grant control-plane read"); } fn app_with_unreachable_convex_without_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: None, enable_legacy_next_compat: false, 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: Some("http://127.0.0.1:9".into()), convex_admin_key: Some("test-admin-key".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(), })) } #[tokio::test] async fn document_shell_unauthenticated_redirects_to_auth_with_next() { let response = app_without_test_actor() .oneshot( Request::builder() .uri("/documents/doc_1?workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( response .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()), Some("/auth?next=%2Fdocuments%2Fdoc_1%3FworkspaceId%3Dws_demo") ); } #[tokio::test] async fn document_shell_missing_local_markdown_redirects_to_folder_navigation() { let root = temp_root("mnote-document-shell-missing-local-md"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("Existing.md"), "# Existing\n") .expect("write existing"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:docs~2FMissing.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=docs" )) .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::SEE_OTHER); let location = response .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_string(); assert!(location.starts_with("/?")); assert!(location.contains("sourceKind=local_folder")); assert!(location.contains("fileTreeScope=docs")); assert!(location.contains("missingPage=local-md%3Adocs%7E2FMissing.md")); } #[tokio::test] async fn document_shell_local_folder_without_root_uri_redirects_to_navigation_home() { let response = app() .oneshot( Request::builder() .uri("/documents/local-md:docs~2FPlan.md?sourceKind=local_folder") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::SEE_OTHER); let location = response .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()) .unwrap_or_default(); assert!(location.starts_with("/?")); assert!(location.contains("routeGuard=local_folder_root_required")); assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md")); } #[tokio::test] async fn document_shell_local_markdown_without_source_kind_does_not_fall_back_to_convex() { let response = app_with_unreachable_convex_without_fixture() .oneshot( Request::builder() .uri("/documents/local-md:docs~2FPlan.md?resourceTab=primary%3A%3Aresource%3Afile%3Afile%3A%2F%2F%2Ftmp%3Adocs%2FPlan.pdf") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_ne!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("convex_retired") ); let location = response .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()) .unwrap_or_default(); assert!(location.starts_with("/?")); assert!(location.contains("routeGuard=local_folder_source_required")); assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md")); } #[tokio::test] async fn document_shell_records_local_markdown_page_recent() { let root = temp_root("mnote-document-shell-record-page-recent"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n正文\n").expect("write plan"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let state = 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: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_test".to_string()), email: Some("user_test@example.com".to_string()), username: "user_test".to_string(), display_name: "user_test".to_string(), role: None, password_hash: None, }) .expect("upsert user"); let app = build_app(state.clone()); let response = app .oneshot( Request::builder() .uri(format!( "/documents/local-md:docs~2FPlan.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=docs" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let recent = state .control_plane() .list_navigation_recent("user_test", Some("page"), 10) .expect("list recent pages"); assert_eq!(recent.len(), 1); assert_eq!( recent[0].document_id.as_deref(), Some("local-md:docs~2FPlan.md") ); assert_eq!(recent[0].relative_path.as_deref(), Some("docs/Plan.md")); assert_eq!(recent[0].title, "Plan"); } #[tokio::test] async fn document_shell_returns_page_aggregate_snapshot() { let response = app() .oneshot( Request::builder() .uri("/documents/doc_1?workspaceId=ws_demo") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .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"); let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; assert!(html.contains("data-testid=\"wolai-sidebar\"")); assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\"")); assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\"")); assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\"")); 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("const currentTarget = resolveTitleTarget(input);")); assert!(html.contains("documentId: currentTarget.documentId")); assert!(html.contains( r#".tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title"# )); assert!(html.contains("const fileTreePageTitle = (value) => {")); assert!(html.contains( "return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;" )); assert!(html.contains( r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"# )); assert!(runtime.contains("row.getAttribute('data-row-id') === `doc:${documentId}`")); assert!(!runtime.contains("row.getAttribute('data-row-id') === `index:${documentId}`")); 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-pi-lab\"")); 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("data-mnote-main-tab-strip")); assert!(html.contains("class=\"mnote-main-tab-badge\"")); assert!(!html.contains(">description"#)); let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS; assert!(runtime.contains("document-resource-tab-runtime.js")); assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)")); assert!(resource_runtime.contains("后台任务")); assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-tab")); assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-clear-completed")); assert!(resource_runtime.contains("role=\"progressbar\"")); assert!(resource_runtime.contains("knowledgeRagTaskCategory(job)")); assert!(resource_runtime.contains("knowledgeRagTaskProgress(job)")); assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1")); assert!(runtime.contains("getOpenEditorsSnapshot")); assert!(resource_runtime.contains("bindMainEditorTabStrip")); assert!(resource_runtime.contains("data-mnote-tab-strip-bound")); assert!(runtime.contains("currentWebShellDocumentId")); assert!(resource_runtime.contains( "const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();" )); assert!(resource_runtime.contains( "if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;" )); assert!(!html .contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()")); assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })")); let slash_runtime = DOCUMENT_SLASH_POSITION_RUNTIME_JS; assert!(slash_runtime.contains("positionSlashMenuForRoot")); assert!(slash_runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');")); assert!(runtime.contains("installGlobalSlashMenuPositioning();")); assert!(slash_runtime.contains("data-mnote-slash-positioned', 'host'")); assert!(slash_runtime.contains( "const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');" )); assert!(slash_runtime.contains("const setSlashMenuInactive = (menu, inactive) =>")); assert!(slash_runtime.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>")); assert!(slash_runtime.contains("data-mnote-slash-inactive")); assert!( slash_runtime.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)") ); assert!(!runtime.contains( "|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')" )); assert!(slash_runtime.contains("data-mnote-side-target-unsupported') !== 'true'")); assert!( runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);") ); assert!(html.contains("data-mnote-resource-tab-host")); assert!(runtime.contains("openPrimaryMindmap")); assert!(runtime.contains("openResourceInActiveTab")); assert!(html.contains( r#""# )); assert!(html.contains( r#""# )); assert!(resource_runtime.contains("/api/local-folder/resource/read")); assert!(resource_runtime.contains("/api/local-folder/resource/write")); assert!(resource_runtime.contains("normalizeEvidenceLocatorInput")); assert!(resource_runtime.contains("applyEvidenceLocatorToEntry")); assert!(resource_runtime.contains("data-mnote-evidence-bbox")); assert!(resource_runtime.contains("data-mnote-evidence-text-highlight")); assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl")); assert!(runtime.contains("sourceMapPath: String(url.searchParams.get('sourceMapPath')")); assert!(runtime.contains("blockId: String(url.searchParams.get('blockId')")); let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS; assert!(sidebar_runtime.contains("openEvidenceSearchResult")); assert!(sidebar_runtime.contains("data-evidence-locator")); let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS; assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap")); assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__")); assert!(mindmap_runtime.contains("data-mnote-object-identity")); assert!(runtime.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("/api/realtime/ws")); let session_runtime = DOCUMENT_SESSION_RUNTIME_JS; assert!(session_runtime.contains("syncPageAggregateScript(session, nextAggregate);")); let conversion_runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS; assert!(conversion_runtime.contains( "const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {" )); assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document")); let block_document_source_index = conversion_runtime .find("if (blockDocument) return 'page_aggregate.block_document';") .expect("blockDocument source should be explicit"); let local_markdown_source_index = conversion_runtime .find("return 'local_markdown.content';") .expect("local markdown legacy fallback should remain explicit"); assert!( block_document_source_index < local_markdown_source_index, "local-first 浏览器转换应优先消费 blockDocument,再降级到 body.content" ); assert!( conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body?.content") ); assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref")); assert!(conversion_runtime.contains("mnote-uploaded-attachment-code")); assert!(conversion_runtime .contains("class: mergeClassNames(mark.attrs.class, attachmentClass)")); assert!(session_runtime .contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);")); assert!(session_runtime.contains( "const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);" )); assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1")); assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()")); assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);")); assert!(session_runtime.contains("data-mnote-page-body-local-compat-fallback")); assert!(session_runtime.contains("data-mnote-page-body-hard-guard")); assert!(session_runtime.contains("local_compat_fallback")); assert!(runtime.contains("data-mnote-page-body-local-compat-fallback")); assert!(runtime.contains("data-mnote-page-body-hard-guard")); assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')")); assert!(session_runtime.contains("|| kind.includes('Modify(Data')")); assert!(session_runtime.contains("|| kind.includes('Modify(Any')")); assert!(!runtime.contains("mnote-web-document-shell")); // Resource open resolver contract assert!(resource_runtime.contains("normalizeResourceTabKind")); assert!(resource_runtime.contains("data-resource-tab-error")); assert!(resource_runtime.contains("resourceTabCloseGuardReason")); assert!(resource_runtime.contains("closeResourceTab")); assert!(resource_runtime.contains("resourceTabRegistry.delete(key)")); assert!(runtime.contains("data-testid=\"mnote-secondary-editor-tab-host\"")); assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\"")); assert!(resource_runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)")); assert!(runtime.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'")); assert!(resource_runtime.contains("data-mnote-editor-kind=\"resource\"")); assert!(resource_runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);")); assert!(resource_runtime.contains("paneRole,")); assert!(resource_runtime.contains("markIntendedSlashRoot(entry);")); assert!(resource_runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);")); assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);")); assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();")); assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)")); assert!(resource_runtime.contains("openInlinePdfResourceTab")); assert!(resource_runtime.contains("data-mnote-inline-pdf-viewer")); assert!(resource_runtime.contains("refreshExistingPdfResourceTab(existing, input);")); assert!(resource_runtime.contains("releaseInlinePdfResource")); assert!( resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>") ); assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;")); assert!(resource_runtime.contains( "if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);" )); assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);")); assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);")); assert!(resource_runtime.contains("data-mnote-active-resource-width-type")); assert!(resource_runtime.contains("mnote:active-resource-tab-changed")); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("activeResourceWidthPreference(options)")); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote:active-resource-tab-changed")); } #[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("compat-join") ); 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"], "CompatMetaContentJoin"); assert_eq!(payload["result"]["projectionVersion"], 1); assert_eq!(payload["result"]["identity"]["documentId"], "doc_1"); assert_eq!(payload["result"]["body"]["revision"], 7); assert_eq!( payload["result"]["body"]["projectionSource"], "editorDocument" ); assert_eq!( payload["result"]["body"]["blockDocument"]["rootBlockIds"][0], "editor_1" ); assert_eq!( payload["result"]["body"]["blockDocument"]["blocks"][0]["text"], "来自 editorDocument 的正文" ); } #[tokio::test] async fn mnote_browser_runtime_assets_are_cacheable() { let response = app() .oneshot( Request::builder() .uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let cache_control = response .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .expect("cache-control"); assert_ne!(cache_control, "no-store"); assert!( cache_control.contains("max-age"), "runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉" ); } #[tokio::test] async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() { let _guard = crate::test_support::hermes_env_lock() .lock() .expect("env lock"); std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); let response = app() .oneshot( Request::builder() .uri("/api/mnote-browser-runtime/tree-live-controller.js") .body(Body::empty()) .expect("request"), ) .await .expect("response"); std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); assert_eq!(response.status(), StatusCode::OK); let cache_control = response .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .expect("cache-control"); assert_eq!( cache_control, "no-store", "dev:hot 下 browser runtime 不能缓存,否则热更新后仍会执行旧 JS" ); } #[tokio::test] async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() { let _guard = crate::test_support::hermes_env_lock() .lock() .expect("env lock"); std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); let response = app() .oneshot( Request::builder() .uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js") .body(Body::empty()) .expect("request"), ) .await .expect("response"); std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let js = String::from_utf8(body.to_vec()).expect("utf8"); assert!( js.contains("from './sidebar-workspace-runtime.js?devHot="), "dev:hot 下 browser runtime 的静态子模块 import 必须跟随同一个 cache buster" ); assert!( js.contains("from './sidebar-filetree-command-runtime.js?devHot="), "文件树命令 runtime 子模块也不能复用旧缓存" ); } #[tokio::test] async fn editor_runtime_preload_links_use_dev_hot_buster() { let _guard = crate::test_support::hermes_env_lock() .lock() .expect("env lock"); std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); let html = super::render_editor_runtime_preload_links(); std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); assert!( html.contains("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js?devHot=") ); assert!(html.contains( "/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm?devHot=" )); } #[test] fn document_editor_adapter_propagates_dev_hot_to_island_runtime() { assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("new URL(import.meta.url)")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS .contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS .contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS .contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)")); } #[tokio::test] async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() { let _guard = crate::test_support::hermes_env_lock() .lock() .expect("env lock"); std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); let response = app() .oneshot( Request::builder() .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") .body(Body::empty()) .expect("request"), ) .await .expect("response"); std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let js = String::from_utf8(body.to_vec()).expect("utf8"); assert!( js.contains("from './snippets/leptos-tiptap-") && js.contains("/bridge_runtime.js?devHot="), "dev:hot 下 wasm-bindgen snippets 子模块不能继续使用旧缓存" ); assert!( js.contains("from \"./snippets/mnote-leptos-tiptap-spike-") && js.contains("/inline0.js?devHot="), "无分号的 wasm-bindgen import 也必须带 cache buster" ); } #[tokio::test] async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() { let _guard = crate::test_support::hermes_env_lock() .lock() .expect("env lock"); std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); let response = app() .oneshot( Request::builder() .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") .body(Body::empty()) .expect("request"), ) .await .expect("response"); std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let js = String::from_utf8(body.to_vec()).expect("utf8"); assert!( js.contains("inline0.js?devHot="), "dev:hot 下 island 必须加载带 cache buster 的页面块导航 bridge" ); } #[tokio::test] async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() { let response = app() .oneshot( Request::builder() .uri("/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js") .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 js = String::from_utf8(body.to_vec()).expect("utf8"); assert!(js.contains("simple-mind-map")); assert!(js.contains("createMindmapParagraphNodeView")); } #[tokio::test] async fn leptos_tiptap_runtime_assets_are_cacheable() { let manifest_response = app() .clone() .oneshot( Request::builder() .uri("/api/leptos-tiptap-runtime/manifest.json") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(manifest_response.status(), StatusCode::OK); let manifest_cache_control = manifest_response .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .expect("manifest cache-control"); assert_ne!(manifest_cache_control, "no-store"); assert!( manifest_cache_control.contains("max-age"), "leptos-tiptap manifest 应允许浏览器缓存,避免每次重新发现 runtime 入口" ); let response = app() .oneshot( Request::builder() .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let cache_control = response .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .expect("cache-control"); assert_ne!(cache_control, "no-store"); assert!( cache_control.contains("max-age"), "leptos-tiptap runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉" ); } #[tokio::test] async fn page_aggregate_endpoint_errors_without_convex_or_fixture() { let response = app_with_unreachable_convex_without_fixture() .oneshot( Request::builder() .uri("/api/page-aggregate/doc_1?workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); if response.status() != StatusCode::SERVICE_UNAVAILABLE { let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); panic!( "expected SERVICE_UNAVAILABLE, got {status}: {}", String::from_utf8_lossy(&body) ); } assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("convex_retired") ); assert_eq!( response .headers() .get("x-error-phase") .and_then(|value| value.to_str().ok()), Some("convex_query_retired") ); assert_eq!( response .headers() .get("x-upstream-service") .and_then(|value| value.to_str().ok()), Some("legacy-cloud-retired") ); 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"], false); assert_eq!(payload["code"], "convex_retired"); assert!(payload.get("schema").is_none()); assert!(payload.get("result").is_none()); } #[tokio::test] async fn document_shell_errors_without_convex_or_fixture() { let response = app_with_unreachable_convex_without_fixture() .oneshot( Request::builder() .uri("/documents/doc_1?workspaceId=ws_demo") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); if response.status() != StatusCode::SERVICE_UNAVAILABLE { let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); panic!( "expected SERVICE_UNAVAILABLE, got {status}: {}", String::from_utf8_lossy(&body) ); } assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("convex_retired") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); let payload: Value = serde_json::from_str(&text).expect("json"); assert_eq!(payload["ok"], false); assert_eq!(payload["code"], "convex_retired"); assert!(!text.contains("mnote.page_aggregate.v1")); assert!(!text.contains("data-mnote-dev-fixture")); assert!(!text.contains("data-page-aggregate-snapshot")); } #[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.join("Local Aggregate")).expect("create local page bundle"); std::fs::write( root.join("Local Aggregate").join("Local Aggregate.md"), "# Local Heading\n正文内容\n", ) .expect("write local md"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .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:Local~20Aggregate~2FLocal~20Aggregate.md" ); // 本地 Markdown 标题来自文件名;正文 H1 只作为正文内容。 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 page_aggregate_endpoint_allows_control_plane_granted_local_folder_read_access() { let root = std::env::temp_dir().join(format!( "mnote-local-page-aggregate-grant-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("Grant Root")).expect("create local page bundle"); std::fs::write( root.join("Grant Root").join("Grant Root.md"), "# Grant Heading\n授权正文\n", ) .expect("write local md"); let root_uri = format!("file://{}", root.display()); let state = 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: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); init_local_workspace(&root, "owner_user"); grant_local_workspace_read_access(&state, "user_test", &root_uri, &root); let context = request_context("user_test", "user"); let aggregate = super::build_page_aggregate_snapshot( &state, &context, "local-md:Grant~20Root~2FGrant~20Root.md", None, Some("local_folder"), Some(&root_uri), ) .await .expect("control-plane read grant can open local page aggregate"); let _ = std::fs::remove_dir_all(&root); assert_eq!( aggregate.identity.document_id, "local-md:Grant~20Root~2FGrant~20Root.md" ); assert_eq!(aggregate.head.title, "Grant Root"); assert!(aggregate.body.content.to_string().contains("Grant Heading")); } #[tokio::test] async fn local_page_aggregate_marks_attachment_refs_authorization_from_control_plane_grants() { let root = temp_root("mnote-local-page-aggregate-attachment-auth-root"); let allowed_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-allowed"); let denied_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-denied"); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("local.pdf"), b"local").expect("local pdf"); let allowed_external = allowed_external_root.join("allowed.pdf"); let denied_external = denied_external_root.join("denied.pdf"); std::fs::write(&allowed_external, b"allowed").expect("allowed external"); std::fs::write(&denied_external, b"denied").expect("denied external"); std::fs::write( root.join("Page").join("Page.md"), format!( "[本页](./local.pdf)\n[授权外部](file://{})\n[未授权外部](file://{})\n", allowed_external.display(), denied_external.display() ), ) .expect("write md"); let root_uri = format!("file://{}", root.display()); let allowed_root_uri = format!("file://{}", allowed_external_root.display()); let state = 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: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); init_local_workspace(&root, "owner_user"); grant_local_workspace_read_access(&state, "user_test", &root_uri, &root); grant_local_workspace_read_access(&state, "user_other", &root_uri, &root); grant_local_workspace_read_access( &state, "user_test", &allowed_root_uri, &allowed_external_root, ); let context = request_context("user_test", "user"); let aggregate = super::build_page_aggregate_snapshot( &state, &context, "local-md:Page~2FPage.md", None, Some("local_folder"), Some(&root_uri), ) .await .expect("aggregate with attachment refs"); let refs = aggregate.body.attachment_refs.as_array().expect("refs"); let by_label = |label: &str| { refs.iter() .find(|item| item["label"].as_str() == Some(label)) .expect("attachment ref") }; assert_eq!(by_label("本页")["authorized"], true); assert_eq!(by_label("授权外部")["authorized"], true); assert_eq!(by_label("未授权外部")["authorized"], false); let other_context = request_context("user_other", "user"); let other_aggregate = super::build_page_aggregate_snapshot( &state, &other_context, "local-md:Page~2FPage.md", None, Some("local_folder"), Some(&root_uri), ) .await .expect("other user aggregate with attachment refs"); let other_refs = other_aggregate .body .attachment_refs .as_array() .expect("other refs"); let other_allowed_external = other_refs .iter() .find(|item| item["label"].as_str() == Some("授权外部")) .expect("other allowed external ref"); assert_eq!(other_allowed_external["authorized"], false); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&allowed_external_root); let _ = std::fs::remove_dir_all(&denied_external_root); } #[tokio::test] async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() { let root = std::env::temp_dir().join(format!( "mnote-local-page-aggregate-runtime-asset-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("Cold Start")).expect("create local page bundle"); std::fs::write( root.join("Cold Start").join("Cold Start.md"), "# Cold Start\n\nEditor cold start target.\n", ) .expect("write local md"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let app = app(); let aggregate_app = app.clone(); let aggregate_uri = format!( "/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}" ); super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.store(400, std::sync::atomic::Ordering::SeqCst); struct ResetLocalAggregateBlock; impl Drop for ResetLocalAggregateBlock { fn drop(&mut self) { super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS .store(0, std::sync::atomic::Ordering::SeqCst); } } let _reset_block = ResetLocalAggregateBlock; let aggregate_task = tokio::spawn(async move { aggregate_app .oneshot( Request::builder() .uri(aggregate_uri) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("aggregate request"), ) .await .expect("aggregate response") }); let started = Instant::now(); tokio::task::yield_now().await; let asset_response = app .oneshot( Request::builder() .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") .body(Body::empty()) .expect("asset request"), ) .await .expect("asset response"); let asset_elapsed = started.elapsed(); let aggregate_response = aggregate_task.await.expect("aggregate task"); let _ = std::fs::remove_dir_all(&root); assert_eq!(asset_response.status(), StatusCode::OK); assert_eq!(aggregate_response.status(), StatusCode::OK); assert!( asset_elapsed < Duration::from_millis(150), "leptos-tiptap runtime asset 不应被本地 Page Aggregate 冷构建阻塞,实际等待 {asset_elapsed:?}" ); } #[tokio::test] async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header( ) { let root = std::env::temp_dir().join(format!( "mnote-local-page-aggregate-ui-pref-{}", 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"), "# Readme Heading\n正文\n").expect("write local md"); let root_uri = format!("file://{}", root.display()); let state = 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: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); grant_local_workspace_read_access(&state, "user_test", &root_uri, &root); let workspace_id = crate::routes::local_folder_source::local_workspace_id_from_root_uri(&root_uri) .expect("workspace id"); state .control_plane() .upsert_user_ui_preference(UpsertUserUiPreferenceInput { id: None, user_id: "user_test".into(), workspace_id: Some(workspace_id), source_kind: Some("local_folder".into()), scope_kind: "source_family".into(), scope_id: "external_local_folder".into(), key: "hideTitleHeader".into(), value_json: "false".into(), }) .expect("upsert title header preference"); let context = request_context("user_test", "user"); let aggregate = super::build_page_aggregate_snapshot( &state, &context, "local-md:README.md", None, Some("local_folder"), Some(&root_uri), ) .await .expect("local page aggregate"); let _ = std::fs::remove_dir_all(&root); assert!(!aggregate.layout.page_options.hide_title_header); } #[tokio::test] async fn ui_preferences_api_updates_and_returns_effective_page_options() { let app = app(); let response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/ui/preferences") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "workspaceId": "ws_demo", "sourceKind": "convex_workspace", "documentId": "doc_1", "updates": { "showHeadingNumbers": true, "layoutDensity": "compact" } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=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 payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["pageOptions"]["showHeadingNumbers"], true); assert_eq!(payload["result"]["pageOptions"]["layoutDensity"], "compact"); assert_eq!(payload["result"]["sources"]["showHeadingNumbers"], "global"); assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace"); } #[tokio::test] async fn tree_view_state_api_is_user_scoped_and_tree_scope_isolated() { let app = app(); let put_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/tree/view-state") .header("content-type", "application/json") .header("x-mnote-actor-id", "alice") .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "userId": "mallory", "workspaceId": "default", "sourceKind": "local_folder", "treeKind": "filetree", "rootUri": "file:///mnt/Data1T/mnote", "scope": "design", "state": { "schemaVersion": 1, "treeKind": "filetree", "rootUri": "file:///mnt/Data1T/mnote", "scope": "design", "expandedRelativePaths": ["design/05-editor-mainline"], "expandedIds": [], "selectedId": "local:folder:design/05-editor-mainline", "focusedId": "", "activeId": "", "scrollTop": 12 } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(put_response.status(), StatusCode::OK); let put_body = to_bytes(put_response.into_body(), usize::MAX) .await .expect("body"); let put_payload: Value = serde_json::from_slice(&put_body).expect("json"); assert_eq!(put_payload["result"]["userId"], "alice"); assert_ne!(put_payload["result"]["workspaceId"], "default"); assert!(put_payload["result"]["scopeId"] .as_str() .expect("scope id") .starts_with("filetree:")); let get_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design") .header("x-mnote-actor-id", "alice") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(get_response.status(), StatusCode::OK); let get_body = to_bytes(get_response.into_body(), usize::MAX) .await .expect("body"); let get_payload: Value = serde_json::from_slice(&get_body).expect("json"); assert_eq!(get_payload["result"]["source"], "control-plane"); assert_eq!( get_payload["result"]["state"]["expandedRelativePaths"][0], "design/05-editor-mainline" ); let page_tree_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=pagetree&rootUri=file:///mnt/Data1T/mnote&scope=design") .header("x-mnote-actor-id", "alice") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(page_tree_response.status(), StatusCode::OK); let page_tree_body = to_bytes(page_tree_response.into_body(), usize::MAX) .await .expect("body"); let page_tree_payload: Value = serde_json::from_slice(&page_tree_body).expect("json"); assert_eq!(page_tree_payload["result"]["source"], "default"); assert_eq!( page_tree_payload["result"]["state"]["expandedRelativePaths"] .as_array() .expect("array") .len(), 0 ); let bob_response = app .oneshot( Request::builder() .method("GET") .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design") .header("x-mnote-actor-id", "bob") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(bob_response.status(), StatusCode::OK); let bob_body = to_bytes(bob_response.into_body(), usize::MAX) .await .expect("body"); let bob_payload: Value = serde_json::from_slice(&bob_body).expect("json"); assert_eq!(bob_payload["result"]["source"], "default"); } #[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("Local Shell")).expect("create local page bundle"); std::fs::create_dir_all(root.join("docs").join("Child Page")) .expect("create local child bundle"); std::fs::write( root.join("Local Shell").join("Local Shell.md"), "# Local Shell\n正文\n", ) .expect("write root md"); std::fs::write( root.join("docs").join("Child Page").join("Child Page.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()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .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("\"saveEndpoint\":\"/api/page-body/write\"")); assert!(html.contains("Child Page")); assert!(html.contains("asset.png")); assert!(html.contains("data-row-kind=")); assert!(html.contains("data-page-openable=\"false\"")); assert!(html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js")); assert!(html.contains("/api/mnote-browser-runtime/filetree-runtime.js")); assert!(html.contains(r#""#)); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); let session_runtime = DOCUMENT_SESSION_RUNTIME_JS; assert!(session_runtime.contains("refreshSessionFromExternalFileChange")); assert!(session_runtime.contains("refreshSessionFromExternalChange")); assert!(session_runtime.contains("treeExternalConflictMessage")); assert!(session_runtime.contains("tree:delta")); assert!(session_runtime.contains("tree:resync")); assert!(session_runtime.contains("mnote-web-tree-live")); assert!(session_runtime.contains("refreshMindmapRuntimesFromTreePayload")); assert!(session_runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__")); assert!(session_runtime.contains("/api/local-folder/events")); assert!(session_runtime.contains("localFolderEventChannelKey")); assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);")); assert!(session_runtime.contains( "if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;" )); assert!(session_runtime.contains("new EventSource(url.toString())")); assert!(session_runtime.contains("localFolderEventRegistry")); assert!(session_runtime.contains("if (!response.ok) {")); assert!(session_runtime.contains( "if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found'))" )); assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange")); assert!(session_runtime.contains("kind.includes('Modify(Name')")); assert!(session_runtime.contains("targetSession.views.size === 0")); assert!(session_runtime.contains("session.views.size === 0")); assert!(session_runtime.contains("targetSession.saving")); assert!(session_runtime.contains("lastSelfSaveSignalAt")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS .contains("clearSessionConflictSurface(view.session);")); assert!(session_runtime.contains("command: 'replaceContent'")); assert!(session_runtime.contains("external-change-conflict")); assert!(session_runtime.contains("mnote-editor-conflict-panel")); assert!(session_runtime.contains("mnote-conflict-accept-disk")); assert!(session_runtime.contains("mnote-conflict-keep-current")); assert!(session_runtime.contains("mnote-conflict-open-diff")); assert!(session_runtime.contains("mnote-conflict-merge-text")); assert!(session_runtime.contains("mnote-conflict-merge-save")); assert!(session_runtime .contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)")); assert!(session_runtime.contains("agent run")); assert!(!session_runtime.contains( "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" )); } #[tokio::test] async fn document_shell_local_folder_filetree_scope_renders_scoped_page_tree() { let root = std::env::temp_dir().join(format!( "mnote-local-document-shell-scoped-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("design").join("done")).expect("create design done"); std::fs::write(root.join("Home.md"), "# Home\n").expect("write home"); std::fs::write( root.join("design").join("done").join("Target.md"), "# Target\n", ) .expect("write target"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:design~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .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(r#"data-node-id="local-md:design~2Fdone~2FTarget.md""#)); assert!( !html.contains(r#"data-node-id="local-md:Home.md""#), "文档页带 fileTreeScope 时 PageTree 不应回退到 workspace root 全量扫描" ); } #[tokio::test] async fn document_shell_local_folder_filetree_scope_reveals_active_file_parent_chain() { let root = std::env::temp_dir().join(format!( "mnote-local-document-shell-scoped-filetree-reveal-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("design").join("07-ai").join("done")) .expect("create design done"); std::fs::create_dir_all(root.join("design").join("05-editor-mainline")) .expect("create unrelated scope sibling"); std::fs::write( root.join("design") .join("07-ai") .join("done") .join("Target.md"), "# Target\n", ) .expect("write target"); std::fs::write( root.join("design") .join("05-editor-mainline") .join("Other.md"), "# Other\n", ) .expect("write unrelated page"); let root_uri = format!("file://{}", root.display()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:design~2F07-ai~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .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(r#"data-local-relative-path="design/07-ai""#), "scoped FileTree 应保留 active 文档父级 07-ai" ); assert!( html.contains(r#"data-local-relative-path="design/07-ai/done""#), "scoped FileTree 应只 reveal active 文档命中的 done 父链" ); assert!( html.contains(r#"data-local-relative-path="design/07-ai/done/Target.md""#), "active Markdown 文件应在 scoped FileTree 首屏可见" ); assert!( !html.contains(r#"data-local-relative-path="design/05-editor-mainline/Other.md""#), "不相关 sibling 目录不应被 reveal 扫入 scoped FileTree" ); } #[test] fn document_conflict_panel_runtime_contains_dom_helpers() { const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js"); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function clearSessionConflictSurface")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function createSessionConflictPanel")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("data-testid', 'mnote-editor-conflict-panel")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("data-testid', 'mnote-conflict-accept-disk")); assert!( DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-open-diff") ); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onAcceptDisk")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onKeepCurrent")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onOpenDiff")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("function populateSessionConflictDiffPanel")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("data-testid', 'mnote-conflict-current-text")); assert!( DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-disk-text") ); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("data-testid', 'mnote-conflict-merge-use-current")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("data-testid', 'mnote-conflict-merge-use-disk")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("currentMergeText")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseCurrent")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseDisk")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onSaveMerge")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function mountSessionConflictPanel")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("runSessionConflictAction: runSessionConflictAction")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function acceptDiskVersion")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function keepCurrentEditorVersion")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function writeMergedConflictResult")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("keepCurrentEditorVersion: keepCurrentEditorVersion")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS .contains("writeMergedConflictResult: writeMergedConflictResult")); } #[test] fn document_editor_adapter_runtime_contains_host_contracts() { assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("PANES_BOOTSTRAP_ID")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("ROOT_SELECTOR")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("BRIDGE_PROTOCOL")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-mindmap-host-runtime.js")); assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("replacePrimaryPaneMindmap")); assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("openMindmapResourceTab")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js")); assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane")); assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-resource-tab-runtime.js")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("openResourceInActiveTab")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("buildOpenEditorsSnapshot")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("bindMainEditorTabStrip")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-session-runtime.js")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("navigateLocalMarkdownDeletedFallback")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("localFolderNavigationFallbackUrl")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("window.location.assign")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-folder:resource-changed")); assert!( DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("data-mnote-local-ocr-event-stream-retired") ); assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("local_ocr.job.updated")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:knowledge-rag-job-updated")); assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-ocr-job-updated")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS .contains("data-mnote-resource-watch-ready', 'event-bus'")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS .contains("syncPageAggregateScript({ pageAggregateScriptId")); assert!( DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("data-mnote-page-options-local-write-at") ); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("mnote:page-aggregate-synced")); assert!( DOCUMENT_SESSION_RUNTIME_JS.contains("syncPageAggregateScript(session, nextAggregate)") ); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js")); assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition")); assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition")); assert!( DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js") ); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("enhanceEditorAttachmentLinksSoon")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS .contains("window.__mnoteEnhanceEditorAttachmentLinks")); assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap")); } #[tokio::test] async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() { let config = 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: 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(), }; let headers = HeaderMap::new(); let context = RequestContext::from_http_parts( &Method::GET, &"/documents/doc_1?workspaceId=ws_demo" .parse::() .expect("uri"), &headers, ); let sidebar_html = super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await; let filetree_html = super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await; let workspace_projection = super::load_workspace_shell_projection( None, &config, &context, "ws_demo", Some("doc_1"), "个人空间", ) .await; assert!(sidebar_html.is_none()); assert!(filetree_html.is_none()); assert!(workspace_projection.degraded); assert!(workspace_projection.my_page_items.is_empty()); assert!(!workspace_projection.dev_fixture); } #[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()); init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .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")); assert!(html.contains(r#"data-mnote-source-kind="local_folder""#)); assert!(html.contains(r#"data-mnote-root-uri="file://"#)); } #[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(r#""#)); let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS; assert!(runtime.contains("legacyInlineContentToTiptap")); assert!(runtime.contains("legacyStylesToTiptapMarks")); assert!(runtime.contains("legacyMarkArrayToTiptapMarks")); assert!(runtime.contains("const sourcePath = firstNonEmptyText(")); assert!(runtime.contains("block?.props?.sourcePath")); assert!(runtime.contains("marks.push({ type: 'bold' })")); assert!(runtime.contains("marks.push({ type: 'italic' })")); assert!(runtime.contains("marks.push({ type: 'underline' })")); assert!(runtime.contains("marks.push({ type: 'strike' })")); assert!(runtime.contains("marks.push({ type: 'code' })")); assert!(runtime.contains("marks.push({ type: 'link', attrs: { href } })")); assert!(runtime.contains("styles.link = href")); assert!(runtime.contains("contentNodes.map((node) => {")); assert!(runtime.contains("payload: { type: 'text', text")); assert!(runtime.contains("typeof payload.text === 'string'")); assert!(runtime.contains("payload.type === 'hard_break'")); assert!(runtime.contains("typeof body?.fileVersion === 'string'")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("expectedFileVersion: expectedFileVersion")); assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'")); assert!(runtime.contains("blockType: 'mindmap'")); assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)")); assert!(runtime.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)")); assert!(runtime.contains("mnoteBlockType: 'mindmap'")); assert!(runtime.contains("block.blockType === 'mindmap'")); assert!(runtime.contains("content: block.blockType === 'mindmap'")); assert!(runtime.contains("? ''")); assert!(!runtime.contains( "block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')" )); } #[test] fn mindmap_resize_runtime_contract_includes_dimension_attrs() { let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS; assert!(runtime.contains("mindmapWidth")); assert!(runtime.contains("mindmapHeight")); assert!(runtime.contains("data?.mindmap_width")); assert!(runtime.contains("dataset.mnoteMindmapWidth")); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("--mnote-mindmap-block-max-width")); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("currentPageWidthPreferences().mindmap")); } }