1729 lines
63 KiB
Rust
1729 lines
63 KiB
Rust
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<String>,
|
|
pub source_kind: Option<String>,
|
|
pub root_uri: 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>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LocalSearchIndexRefreshRequest {
|
|
pub workspace_id: Option<String>,
|
|
pub root_uri: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LocalSearchIndexSettingsRequest {
|
|
pub workspace_id: Option<String>,
|
|
pub root_uri: String,
|
|
pub include_paths: Vec<String>,
|
|
pub schedule_mode: Option<String>,
|
|
pub schedule_time: Option<String>,
|
|
pub schedule_date: Option<String>,
|
|
pub run_on_change: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LocalSearchIndexQuery {
|
|
pub workspace_id: Option<String>,
|
|
pub root_uri: String,
|
|
pub document_id: 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 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! {
|
|
<SearchPage
|
|
workspace_id={workspace_id.to_string()}
|
|
search_query={search_query.to_string()}
|
|
sidebar_tree_html={sidebar_tree_html}
|
|
initial_results_html={render_initial_results_html(initial_results.get("results").and_then(Value::as_array))}
|
|
/>
|
|
});
|
|
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 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<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Json(body): Json<LocalSearchIndexRefreshRequest>,
|
|
) -> 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 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<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Query(query): Query<LocalSearchIndexQuery>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Json(body): Json<LocalSearchIndexSettingsRequest>,
|
|
) -> 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 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<local_search_index::LocalIndexSettings, WebError> {
|
|
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<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Query(query): Query<LocalSearchIndexQuery>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Query(query): Query<LocalSearchIndexQuery>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<String>,
|
|
limit: u32,
|
|
) -> Result<Value, WebError> {
|
|
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<String>,
|
|
limit: u32,
|
|
filters: SearchDocumentsFilters,
|
|
) -> Result<Value, WebError> {
|
|
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<Value>>) -> String {
|
|
let Some(results) = results else {
|
|
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
|
|
};
|
|
if results.is_empty() {
|
|
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.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#"<a class="search-result" data-search-result-owner="rust-kernel" href="{}"><strong>{}</strong><span>{}</span></a>"#,
|
|
escape_html(href),
|
|
escape_html(title),
|
|
escape_html(snippet)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
format!(r#"<div class="search-result-list">{items}</div>"#)
|
|
}
|
|
|
|
fn escape_script_json(value: &str) -> String {
|
|
value.replace("</script", "<\\/script")
|
|
}
|
|
|
|
fn escape_html(value: &str) -> 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);
|
|
}
|
|
}
|