use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::gateway::current_actor_id; use crate::routes::query_support::{ execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id, }; use crate::routes::web_shell::load_sidebar_tree_html; use crate::routes::{local_folder_source, local_search_index}; 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 source_kind: Option, pub root_uri: 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, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSearchIndexRefreshRequest { pub workspace_id: Option, pub root_uri: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSearchIndexSettingsRequest { pub workspace_id: Option, pub root_uri: String, pub include_paths: Vec, pub schedule_mode: Option, pub schedule_time: Option, pub schedule_date: Option, pub run_on_change: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSearchIndexQuery { pub workspace_id: Option, pub root_uri: String, pub document_id: 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 initial_results = load_search_results( state.config(), &context, workspace_id, search_query, None, 20, ) .await .unwrap_or_else(|_| empty_search_projection()); let contract = json!({ "schema": "mnote.search_shell.v1", "owner": "mnote-web", "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "shell": "search", "workspaceId": workspace_id, "query": search_query, "initialResults": { "queryName": "search.documents.query", "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "results": initial_results.get("results").cloned().unwrap_or_else(|| Value::Array(vec![])) }, "island": { "kind": "search_interaction_island", "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 body.source_kind.as_deref().map(str::trim) == Some("local_folder") { let root_uri = body .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri") .with_context(&context) })?; let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let user_settings = resolve_local_index_user_settings( &state, &context, &effective_workspace_id, &root_path, )?; let effective_settings = local_search_index::effective_local_index_settings_for_root( state.control_plane(), &effective_workspace_id, &root_path, )?; let include_ocr = filters.include_ocr.unwrap_or(false); let limit = body.limit.unwrap_or(30); let local_result = local_search_index::query_local_search_index_with_settings( &root_path, root_uri, &effective_workspace_id, &effective_settings, &user_settings, &normalized_query, page_id.as_deref(), limit, filters.title_only.unwrap_or(false), filters.exact.unwrap_or(false), include_ocr, )?; local_result } else { load_search_results_with_filters( state.config(), &context, &effective_workspace_id, &normalized_query, page_id, body.limit.unwrap_or(30), filters, ) .await? }; let results = result .get("results") .cloned() .unwrap_or(Value::Array(vec![])); let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "results": results, "evidence": [], "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])), "meta": { "owner": "mnote-web", "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "queryName": "search.documents.query", "boundary": { "kind": "ordinary_local_search", "knowledgeRag": false, "evidenceSqliteFallback": false, "liteParseFallback": false, "ocrSidecarFallback": false }, "degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)), "degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null), "requestId": context.trace.request_id, "traceId": context.trace.trace_id, }, })), )) } pub async fn refresh_local_index( 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 root_uri = body.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地索引刷新缺少 rootUri", ) .with_context(&context)); } let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let effective_settings = local_search_index::effective_local_index_settings_for_root( state.control_plane(), &effective_workspace_id, &root_path, )?; let refreshed = local_search_index::refresh_local_search_index_with_settings( &root_path, root_uri, &effective_workspace_id, &effective_settings, )?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "index": refreshed, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.refresh", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } pub async fn local_index_status( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let root_uri = query.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地索引状态缺少 rootUri", ) .with_context(&context)); } let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let user_settings = resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?; let effective_settings = local_search_index::effective_local_index_settings_for_root( state.control_plane(), &effective_workspace_id, &root_path, )?; let status = local_search_index::local_index_status_with_settings( &root_path, root_uri, &effective_workspace_id, &user_settings, &effective_settings, )?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "result": status, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.status", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } pub async fn update_local_index_settings( 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 root_uri = body.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地索引设置缺少 rootUri", ) .with_context(&context)); } let root_path = local_folder_source::ensure_local_workspace_write_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let actor_id = current_actor_id(&state, &context).ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, "local_index_settings_auth_required", "本地索引设置需要登录用户", ) .with_context(&context) })?; let settings = local_search_index::write_user_local_index_settings( state.control_plane(), &actor_id, &effective_workspace_id, &root_path, &body.include_paths, body.schedule_mode.as_deref(), body.schedule_time.as_deref(), body.schedule_date.as_deref(), body.run_on_change, )?; let effective_settings = local_search_index::effective_local_index_settings_for_root( state.control_plane(), &effective_workspace_id, &root_path, )?; let status = local_search_index::local_index_status_with_settings( &root_path, root_uri, &effective_workspace_id, &settings, &effective_settings, )?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "settings": settings, "result": status, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.settings.update", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } fn resolve_local_index_user_settings( state: &AppState, context: &RequestContext, workspace_id: &str, root_path: &std::path::Path, ) -> Result { if let Some(actor_id) = current_actor_id(state, context) { return local_search_index::read_user_local_index_settings( state.control_plane(), &actor_id, workspace_id, root_path, ); } local_search_index::read_local_index_settings_or_default(root_path) } pub async fn local_index_backlinks( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let root_uri = query.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地反链查询缺少 rootUri", ) .with_context(&context)); } let document_id = query .document_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code( "local_search_document_required", "本地反链查询缺少 documentId", ) .with_context(&context) })?; let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let user_settings = resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?; let effective_settings = local_search_index::effective_local_index_settings_for_root( state.control_plane(), &effective_workspace_id, &root_path, )?; let backlinks = local_search_index::query_local_backlinks_with_settings( &root_path, root_uri, &effective_workspace_id, &effective_settings, &user_settings, document_id, )?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "result": backlinks, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.backlinks", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } pub async fn local_index_tags( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let root_uri = query.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地标签查询缺少 rootUri", ) .with_context(&context)); } let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let user_settings = resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?; let effective_settings = local_search_index::effective_local_index_settings_for_root( state.control_plane(), &effective_workspace_id, &root_path, )?; let tags = local_search_index::query_local_tags_with_settings( &root_path, root_uri, &effective_workspace_id, &effective_settings, &user_settings, )?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "result": tags, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.tags", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } async fn load_search_results( config: &crate::app::AppConfig, context: &RequestContext, workspace_id: &str, query: &str, page_id: Option, limit: u32, ) -> Result { load_search_results_with_filters( config, context, workspace_id, query, page_id, limit, SearchDocumentsFilters::default(), ) .await } async fn load_search_results_with_filters( config: &crate::app::AppConfig, context: &RequestContext, workspace_id: &str, query: &str, page_id: Option, limit: u32, filters: SearchDocumentsFilters, ) -> Result { if query.trim().is_empty() { return Ok(empty_search_projection()); } let runtime_query = RuntimeQueryEnvelopeWire { name: "search.documents.query".into(), payload: json!({ "query": query, "workspaceId": workspace_id, "pageId": page_id, "limit": limit, "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()), }), }; match execute_runtime_query_via_legacy_cloud( config, context, Some(workspace_id), runtime_query.clone(), ) .await { Ok(value) => Ok(value), Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data( context, Some(workspace_id), runtime_query, fallback_search_dataset(workspace_id), ), Err(error) => Ok(degraded_empty_search_projection(error.message())), } } fn empty_search_projection() -> Value { json!({ "enqueueAssetIds": [], "projectionOwner": "rust-kernel", "results": [], }) } fn degraded_empty_search_projection(reason: &str) -> Value { json!({ "enqueueAssetIds": [], "projectionOwner": "rust-kernel", "results": [], "degraded": true, "degradedReason": reason, }) } fn fallback_search_dataset(workspace_id: &str) -> Value { json!({ "documents": [ { "id": "doc_1", "workspaceId": workspace_id, "title": "Rust Web 搜索结果", "rawText": "mnote-web search documents transport", "createdAt": "2026-04-28T00:00:00Z", "updatedAt": "2026-04-28T00:00:00Z" }, { "id": "doc_hermes", "workspaceId": workspace_id, "title": "Hermes", "rawText": "Hermes 技能知识图谱开发 Wolai aline fixture", "createdAt": "2026-04-30T00:00:00Z", "updatedAt": "2026-04-30T00:00:00Z" }, { "id": "doc_hermes_skill", "workspaceId": workspace_id, "title": "技能知识图谱开发", "rawText": "Hermes 页面路径 个人 软件开发", "createdAt": "2026-04-30T00:00:00Z", "updatedAt": "2026-04-30T00:00:00Z" } ], "mindmaps": [], "tables": [], "tableRows": [], "assets": [] }) } fn render_initial_results_html(results: Option<&Vec>) -> String { let Some(results) = results else { return r#"
暂无结果
"#.into(); }; if results.is_empty() { return r#"
暂无结果
"#.into(); } let items = results .iter() .map(|item| { let title = item .get("title") .and_then(Value::as_str) .unwrap_or("无标题"); let snippet = item .get("snippet") .and_then(Value::as_str) .unwrap_or_default(); let href = item .get("publicPath") .and_then(Value::as_str) .unwrap_or("#"); format!( r#"{}{}"#, escape_html(href), escape_html(title), escape_html(snippet) ) }) .collect::>() .join(""); format!(r#"
{items}
"#) } fn escape_script_json(value: &str) -> String { value.replace(" String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn stamp_search_headers(headers: &mut HeaderMap) { 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_QUERY_NAME.as_bytes()) { headers.insert(name, HeaderValue::from_static("search.documents")); } } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use crate::routes::local_search_index; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use control_plane::{DirectoryGrantInput, UpsertUserInput}; use serde_json::{json, Value}; use std::fs; use tower::util::ServiceExt; fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, 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(), })) } fn query_escape(value: &str) -> String { value .bytes() .map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { (byte as char).to_string() } _ => format!("%{byte:02X}"), }) .collect() } #[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("search_interaction_island")); assert!(html.contains("search.documents.query")); assert!(html.contains("search-result")); assert!(html.contains("Rust Web 搜索结果")); assert!(html.contains("data-mnote-dev-fixture=\"true\"")); assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\"")); } #[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.query"); assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel"); assert_eq!(payload["projectionOwner"], "rust-kernel"); } #[tokio::test] async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() { let app = 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, 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 response = app .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .body(Body::from( json!({ "workspaceId": "ws_demo", "query": "Hermes" }) .to_string(), )) .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["results"], json!([])); assert_eq!(payload["meta"]["degraded"], true); assert!(!payload.to_string().contains("Hermes 技能知识图谱开发")); } #[tokio::test] async fn search_documents_local_folder_uses_authorized_root_index() { let root = std::env::temp_dir().join(format!("mnote-local-search-route-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join("docs")).expect("docs"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-search","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write( root.join("README.md"), "---\ntitle: Search Home\ntags: [alpha]\n---\n# Search Home\nAlpha body links [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n", ) .expect("readme"); fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child"); let root_uri = format!("file://{}", root.display()); local_search_index::write_local_index_settings( &root, &[String::from(".")], None, None, None, None, ) .expect("settings"); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-search", "sourceKind": "local_folder", "rootUri": root_uri, "query": "alpha", "limit": 10 }) .to_string(), )) .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["meta"]["degraded"], false); let results = payload["results"].as_array().expect("results"); let home = results .iter() .find(|item| item["documentId"].as_str() == Some("local-md:README.md")) .expect("home result"); assert_eq!(home["sourceKind"].as_str(), Some("local_folder")); assert_eq!(home["resourceType"].as_str(), Some("markdown")); assert_eq!( payload["meta"]["boundary"]["kind"].as_str(), Some("ordinary_local_search") ); assert_eq!( payload["meta"]["boundary"]["knowledgeRag"].as_bool(), Some(false) ); assert_eq!( payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(), Some(false) ); assert_eq!( payload["meta"]["boundary"]["kind"].as_str(), Some("ordinary_local_search") ); assert_eq!( payload["meta"]["boundary"]["ocrSidecarFallback"].as_bool(), Some(false) ); assert_eq!( payload["meta"]["boundary"]["knowledgeRag"].as_bool(), Some(false) ); assert!(payload["evidence"].as_array().expect("evidence").is_empty()); assert!(home["tags"] .as_array() .unwrap() .iter() .any(|tag| tag.as_str() == Some("alpha"))); assert!(home["backlinks"] .as_array() .unwrap() .iter() .any(|link| link.as_str() == Some("Daily"))); assert!(home["resourceRefs"] .as_array() .unwrap() .iter() .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); assert!(root .join(".mnote") .join("index") .join("search-index.json") .exists()); let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite"); assert!(evidence_db.exists(), "evidence sqlite should be built"); let connection = rusqlite::Connection::open(&evidence_db).expect("open evidence sqlite"); let resource_count: i64 = connection .query_row("SELECT COUNT(*) FROM evidence_resource", [], |row| { row.get(0) }) .expect("resource count"); let block_count: i64 = connection .query_row("SELECT COUNT(*) FROM evidence_block", [], |row| row.get(0)) .expect("block count"); let fts_count: i64 = connection .query_row("SELECT COUNT(*) FROM evidence_fts", [], |row| row.get(0)) .expect("fts count"); assert!(resource_count >= 1); assert!(block_count >= 1); assert!(fts_count >= 1); let locator_json: String = connection .query_row( "SELECT locator_json FROM evidence_block LIMIT 1", [], |row| row.get(0), ) .expect("locator json"); let locator: Value = serde_json::from_str(&locator_json).expect("locator"); assert_eq!( locator["schema"].as_str(), Some("mnote.evidence_locator.v1") ); assert!(payload["recent"] .as_array() .unwrap() .iter() .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn search_documents_local_folder_does_not_promote_evidence_sqlite_body_hits() { let root = std::env::temp_dir().join(format!( "mnote-local-search-evidence-route-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-search-evidence","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write( root.join("README.md"), "# Search Home\nBodyOnlyEvidenceToken only exists inside evidence.sqlite after refresh.\n", ) .expect("readme"); let root_uri = format!("file://{}", root.display()); local_search_index::write_local_index_settings( &root, &[String::from(".")], None, None, None, None, ) .expect("settings"); local_search_index::refresh_local_search_index( &root, &root_uri, "local-ws-search-evidence", ) .expect("refresh"); fs::write( root.join(".mnote").join("index").join("search-index.json"), serde_json::to_string_pretty(&json!({ "version": 1, "builtAt": 1, "rootUri": root_uri, "workspaceId": "local-ws-search-evidence", "documents": [], "resources": [] })) .expect("stale search index"), ) .expect("overwrite search index"); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-search-evidence", "sourceKind": "local_folder", "rootUri": root_uri, "query": "BodyOnlyEvidenceToken", "limit": 10, "filters": { "titleOnly": false, "includeOcr": true } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let results = payload["results"].as_array().expect("results"); assert!(!results.iter().any(|item| item["id"] .as_str() .unwrap_or_default() .starts_with("evidence:"))); assert!(results .iter() .any(|item| item["resourceType"].as_str() == Some("markdown"))); assert_eq!( payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(), Some(false) ); assert!(payload["evidence"].as_array().expect("evidence").is_empty()); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn search_local_index_refresh_rebuilds_authorized_root() { let root = std::env::temp_dir().join(format!( "mnote-local-search-refresh-route-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-refresh","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme"); let root_uri = format!("file://{}", root.display()); local_search_index::write_local_index_settings( &root, &[String::from(".")], None, None, None, None, ) .expect("settings"); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/search/local-index/refresh") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-refresh", "rootUri": root_uri }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["index"]["documentCount"], 1); assert_eq!( payload["meta"]["queryName"].as_str(), Some("search.local_index.refresh") ); let index = fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) .expect("index"); assert!(index.contains("refresh-token")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn local_index_settings_empty_scope_deletes_index_files() { let root = std::env::temp_dir().join(format!( "mnote-local-search-settings-delete-route-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join("docs")).expect("docs"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-settings-delete","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files","search"]}"#, ) .expect("manifest"); fs::write( root.join("docs").join("keep.md"), "# Keep\nRouteDeleteToken\n", ) .expect("doc"); let root_uri = format!("file://{}", root.display()); let app = app(); let create_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/search/local-index/settings") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings-delete", "rootUri": root_uri, "includePaths": ["docs"], "scheduleMode": "manual", "scheduleTime": "02:00", "runOnChange": false }) .to_string(), )) .expect("create settings request"), ) .await .expect("create settings response"); assert_eq!(create_response.status(), StatusCode::OK); let create_refresh_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/search/local-index/refresh") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings-delete", "rootUri": root_uri }) .to_string(), )) .expect("create refresh request"), ) .await .expect("create refresh response"); assert_eq!(create_refresh_response.status(), StatusCode::OK); assert!(root.join(".mnote/index/search-index.json").exists()); assert!(root.join(".mnote/index/evidence.sqlite").exists()); let delete_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/search/local-index/settings") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings-delete", "rootUri": root_uri, "includePaths": [], "scheduleMode": "manual", "scheduleTime": "02:00", "runOnChange": false }) .to_string(), )) .expect("delete settings request"), ) .await .expect("delete settings response"); assert_eq!(delete_response.status(), StatusCode::OK); let delete_body = to_bytes(delete_response.into_body(), usize::MAX) .await .expect("delete body"); let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json"); assert_eq!( delete_payload["result"]["settings"]["includePaths"] .as_array() .map(Vec::len), Some(0) ); assert!(root.join(".mnote/index/search-index.json").exists()); assert!(root.join(".mnote/index/evidence.sqlite").exists()); let delete_refresh_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/search/local-index/refresh") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings-delete", "rootUri": root_uri }) .to_string(), )) .expect("delete refresh request"), ) .await .expect("delete refresh response"); assert_eq!(delete_refresh_response.status(), StatusCode::OK); assert!(!root.join(".mnote/index/search-index.json").exists()); assert!(!root.join(".mnote/index/evidence.sqlite").exists()); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn search_local_index_backlinks_and_tags_read_authorized_root() { let root = std::env::temp_dir().join(format!( "mnote-local-search-backlinks-route-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join("docs")).expect("docs"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-backlinks","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write( root.join("README.md"), "---\ntitle: Home\ntags: [alpha]\n---\n# Home\nSee [Child](docs/child.md).\n", ) .expect("home"); fs::write( root.join("docs").join("child.md"), "---\ntitle: Child\ntags: [alpha, beta]\n---\n# Child\n", ) .expect("child"); let root_uri = format!("file://{}", root.display()); local_search_index::write_local_index_settings( &root, &[String::from(".")], None, None, None, None, ) .expect("settings"); let encoded_root = query_escape(&root_uri); let backlinks_response = app() .oneshot( Request::builder() .method("GET") .uri(format!( "/api/search/local-index/backlinks?workspaceId=local-ws-backlinks&rootUri={encoded_root}&documentId=local-md:docs~2Fchild.md" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("backlinks response"); assert_eq!(backlinks_response.status(), StatusCode::OK); let backlinks_body = to_bytes(backlinks_response.into_body(), usize::MAX) .await .expect("backlinks body"); let backlinks_payload: Value = serde_json::from_slice(&backlinks_body).expect("json"); assert_eq!( backlinks_payload["meta"]["queryName"].as_str(), Some("search.local_index.backlinks") ); assert!(backlinks_payload["result"]["backlinks"] .as_array() .unwrap() .iter() .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); let tags_response = app() .oneshot( Request::builder() .method("GET") .uri(format!( "/api/search/local-index/tags?workspaceId=local-ws-backlinks&rootUri={encoded_root}" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("tags response"); assert_eq!(tags_response.status(), StatusCode::OK); let tags_body = to_bytes(tags_response.into_body(), usize::MAX) .await .expect("tags body"); let tags_payload: Value = serde_json::from_slice(&tags_body).expect("json"); assert!(tags_payload["result"]["tags"] .as_array() .unwrap() .iter() .any(|tag| tag["tag"].as_str() == Some("alpha") && tag["count"].as_u64() == Some(2))); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn local_index_settings_route_keeps_user_settings_and_shared_effective_scope() { let root = std::env::temp_dir().join(format!( "mnote-local-search-settings-route-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join("docs").join("alice")).expect("alice dir"); fs::create_dir_all(root.join("docs").join("bob")).expect("bob dir"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-settings","ownerId":"alice","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write( root.join("docs").join("alice").join("keep.md"), "# Alice\nAliceRouteToken\n", ) .expect("alice doc"); fs::write( root.join("docs").join("bob").join("keep.md"), "# Bob\nBobRouteToken\n", ) .expect("bob doc"); let root_uri = format!("file://{}", root.display()); let encoded_root = query_escape(&root_uri); 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, 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("alice".into()), email: None, username: "alice".into(), display_name: "alice".into(), role: None, password_hash: None, }) .expect("upsert alice"); state .control_plane() .upsert_user(UpsertUserInput { id: Some("bob".into()), email: None, username: "bob".into(), display_name: "bob".into(), role: None, password_hash: None, }) .expect("upsert bob"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "bob".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root.display().to_string(), permission: "write".into(), recursive: true, capabilities: vec!["ai".into()], source: "test".into(), created_by: Some("alice".into()), }) .expect("grant bob local folder access"); let app = build_app(state); let alice_settings_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/search/local-index/settings") .header("content-type", "application/json") .header("x-mnote-actor-id", "alice") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings", "rootUri": root_uri, "includePaths": ["docs/alice"], "scheduleMode": "manual", "scheduleTime": "02:00", "runOnChange": false }) .to_string(), )) .expect("alice settings request"), ) .await .expect("alice settings response"); assert_eq!(alice_settings_response.status(), StatusCode::OK); let bob_settings_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/search/local-index/settings") .header("content-type", "application/json") .header("x-mnote-actor-id", "bob") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings", "rootUri": root_uri, "includePaths": ["docs/bob"], "scheduleMode": "manual", "scheduleTime": "02:00", "runOnChange": true }) .to_string(), )) .expect("bob settings request"), ) .await .expect("bob settings response"); assert_eq!(bob_settings_response.status(), StatusCode::OK); let alice_status_response = app .clone() .oneshot( Request::builder() .method("GET") .uri(format!( "/api/search/local-index/status?workspaceId=local-ws-settings&rootUri={encoded_root}" )) .header("x-mnote-actor-id", "alice") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("alice status request"), ) .await .expect("alice status response"); assert_eq!(alice_status_response.status(), StatusCode::OK); let alice_status_body = to_bytes(alice_status_response.into_body(), usize::MAX) .await .expect("alice status body"); let alice_status_payload: Value = serde_json::from_slice(&alice_status_body).expect("alice status json"); assert_eq!( alice_status_payload["result"]["settings"]["includePaths"] .as_array() .map(Vec::len), Some(1) ); assert_eq!( alice_status_payload["result"]["effectiveSettings"]["includePaths"] .as_array() .map(Vec::len), Some(2) ); assert_eq!( alice_status_payload["result"]["effectiveSettings"]["runOnChange"].as_bool(), Some(true) ); let alice_search_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .header("x-mnote-actor-id", "alice") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings", "sourceKind": "local_folder", "rootUri": root_uri, "query": "BobRouteToken", "limit": 10 }) .to_string(), )) .expect("alice search request"), ) .await .expect("alice search response"); assert_eq!(alice_search_response.status(), StatusCode::OK); let alice_search_body = to_bytes(alice_search_response.into_body(), usize::MAX) .await .expect("alice search body"); let alice_search_payload: Value = serde_json::from_slice(&alice_search_body).expect("alice search json"); assert_eq!( alice_search_payload["results"].as_array().map(Vec::len), Some(0) ); let bob_search_response = app .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .header("x-mnote-actor-id", "bob") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-settings", "sourceKind": "local_folder", "rootUri": root_uri, "query": "BobRouteToken", "limit": 10 }) .to_string(), )) .expect("bob search request"), ) .await .expect("bob search response"); assert_eq!(bob_search_response.status(), StatusCode::OK); let bob_search_body = to_bytes(bob_search_response.into_body(), usize::MAX) .await .expect("bob search body"); let bob_search_payload: Value = serde_json::from_slice(&bob_search_body).expect("bob search json"); assert_eq!( bob_search_payload["results"].as_array().map(Vec::len), Some(1) ); let _ = fs::remove_dir_all(&root); } }