334 lines
11 KiB
Rust
334 lines
11 KiB
Rust
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<String>,
|
|
pub query: Option<String>,
|
|
pub document_id: Option<String>,
|
|
pub limit: Option<u32>,
|
|
pub filters: Option<SearchDocumentsFilters>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SearchDocumentsFilters {
|
|
pub title_only: Option<bool>,
|
|
pub exact: Option<bool>,
|
|
pub include_ocr: Option<bool>,
|
|
pub only_current_page: Option<bool>,
|
|
pub time_range: Option<String>,
|
|
pub time_field: Option<String>,
|
|
pub custom_range: Option<SearchCustomRange>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SearchCustomRange {
|
|
pub from: Option<String>,
|
|
pub to: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SearchShellQuery {
|
|
pub workspace_id: Option<String>,
|
|
pub q: Option<String>,
|
|
}
|
|
|
|
pub async fn shell(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Query(query): Query<SearchShellQuery>,
|
|
) -> Result<Response, WebError> {
|
|
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! {
|
|
<SearchPage
|
|
workspace_id={workspace_id.to_string()}
|
|
search_query={search_query.to_string()}
|
|
sidebar_tree_html={sidebar_tree_html}
|
|
/>
|
|
});
|
|
let html = format!(
|
|
r#"<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>搜索</title>
|
|
<style>{}</style>
|
|
</head>
|
|
<body data-mnote-web-owner="mnote-web" data-mnote-shell="search" data-search-shell-owner="rust-web">
|
|
{}
|
|
<script id="__MNOTE_SEARCH_SHELL__" type="application/json">{}</script>
|
|
</body>
|
|
</html>"#,
|
|
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<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Json(body): Json<SearchDocumentsRequest>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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("</script", "<\\/script")
|
|
}
|
|
|
|
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 axum::body::{to_bytes, Body};
|
|
use axum::http::{Request, StatusCode};
|
|
use serde_json::{json, Value};
|
|
use tower::util::ServiceExt;
|
|
|
|
fn app() -> axum::Router {
|
|
build_app(AppState::new(AppConfig {
|
|
service_name: "mnote-web".into(),
|
|
service_version: "0.1.0".into(),
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
enable_legacy_next_compat: true,
|
|
enable_debug_shell_routes: false,
|
|
hermes_base_path: "/api/hermes".into(),
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
convex_url: None,
|
|
convex_admin_key: None,
|
|
allow_dev_fixtures: true,
|
|
query_fixtures_json: 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");
|
|
}
|
|
}
|