use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_against_data, resolve_effective_workspace_id, }; use crate::routes::web_shell::load_sidebar_tree_html; use crate::ssr::pages::search::SearchPage; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use serde::Deserialize; use serde_json::{json, Value}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_QUERY_NAME: &str = "x-query-name"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentsRequest { pub workspace_id: Option, pub query: Option, pub document_id: Option, pub limit: Option, pub filters: Option, } #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentsFilters { pub title_only: Option, pub exact: Option, pub include_ocr: Option, pub only_current_page: Option, pub time_range: Option, pub time_field: Option, pub custom_range: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchCustomRange { pub from: Option, pub to: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchShellQuery { pub workspace_id: Option, pub q: Option, } pub async fn shell( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { let workspace_id = query .workspace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("default"); let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None) .await .unwrap_or_default(); let search_query = query.q.as_deref().map(str::trim).unwrap_or(""); let contract = json!({ "schema": "mnote.search_shell.v1", "owner": "mnote-web", "shell": "search", "workspaceId": workspace_id, "query": search_query, "initialResults": { "queryName": "search.documents", "results": [] }, "island": { "kind": "react_search_palette", "mountId": "mnote-search-island", "runtime": "SearchPaletteHost" }, "requestId": context.trace.request_id, "traceId": context.trace.trace_id }); let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string()); let body_content = crate::ssr::render_view(leptos::view! { }); let html = format!( r#" 搜索 {} "#, crate::ssr::MNOTE_CSS, body_content, escape_script_json(&contract_json), ); let mut response = Html(html).into_response(); stamp_search_headers(response.headers_mut()); if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-shell") { response .headers_mut() .insert(name, HeaderValue::from_static("search")); } Ok(response) } pub async fn documents( State(_state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let filters = body.filters.unwrap_or_default(); let normalized_query = body.query.unwrap_or_default().trim().to_string(); let page_id = if filters.only_current_page.unwrap_or(false) { body.document_id.filter(|value| !value.trim().is_empty()) } else { None }; let result = if normalized_query.is_empty() { json!({ "enqueueAssetIds": [], "results": [], }) } else { execute_runtime_query_against_data( &context, Some(&effective_workspace_id), RuntimeQueryEnvelopeWire { name: "search.documents".into(), payload: json!({ "query": normalized_query, "workspaceId": effective_workspace_id, "pageId": page_id, "limit": body.limit.unwrap_or(30), "titleOnly": filters.title_only.unwrap_or(false), "exact": filters.exact.unwrap_or(false), "includeOcr": filters.include_ocr.unwrap_or(false), "timeRange": filters.time_range.unwrap_or_else(|| "any".into()), "timeField": filters.time_field.unwrap_or_else(|| "updated".into()), "customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()), "customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()), }), }, json!({ "documents": [ { "id": "doc_1", "workspaceId": effective_workspace_id, "title": "Rust Web 搜索结果", "rawText": "mnote-web search documents transport", "createdAt": "2026-04-28T00:00:00Z", "updatedAt": "2026-04-28T00:00:00Z" } ], "mindmaps": [], "tables": [], "tableRows": [], "assets": [] }), )? }; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "results": result.get("results").cloned().unwrap_or(Value::Array(vec![])), "recent": [], "meta": { "owner": "mnote-web", "queryName": "search.documents", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, }, })), )) } fn escape_script_json(value: &str) -> String { value.replace(" axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: 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 search_shell_returns_server_first_island_contract() { let response = app() .oneshot( Request::builder() .uri("/search?workspaceId=ws_demo&q=Rust") .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("search") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("data-mnote-shell=\"search\"")); assert!(html.contains("mnote.search_shell.v1")); assert!(html.contains("react_search_palette")); assert!(html.contains("search.documents")); } #[tokio::test] async fn search_documents_route_is_owned_by_mnote_web() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .body(Body::from( json!({ "workspaceId": "ws_demo", "query": "Rust Web", "filters": { "titleOnly": false, "exact": false, "includeOcr": false, "onlyCurrentPage": false, "timeRange": "any", "timeField": "updated" } }) .to_string(), )) .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-query-name") .and_then(|value| value.to_str().ok()), Some("search.documents") ); 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["meta"]["owner"], "mnote-web"); assert_eq!(payload["meta"]["queryName"], "search.documents"); } }