feat: advance local-first conflict and search indexing
- 补齐本地 markdown 冲突处理与合并写回路径\n- 增加本地搜索索引路由、刷新与 browser smoke\n- 同步更新 current-priority checklist 的阶段进度
This commit is contained in:
@@ -6,6 +6,7 @@ use crate::routes::query_support::{
|
||||
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};
|
||||
@@ -22,6 +23,8 @@ const HEADER_QUERY_NAME: &str = "x-query-name";
|
||||
#[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>,
|
||||
@@ -54,6 +57,21 @@ pub struct SearchShellQuery {
|
||||
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 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>,
|
||||
@@ -151,16 +169,40 @@ pub async fn documents(
|
||||
None
|
||||
};
|
||||
|
||||
let result = load_search_results_with_filters(
|
||||
state.config(),
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
&normalized_query,
|
||||
page_id,
|
||||
body.limit.unwrap_or(30),
|
||||
filters,
|
||||
)
|
||||
.await?;
|
||||
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(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
local_search_index::query_local_search_index(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
&normalized_query,
|
||||
page_id.as_deref(),
|
||||
body.limit.unwrap_or(30),
|
||||
filters.title_only.unwrap_or(false),
|
||||
filters.exact.unwrap_or(false),
|
||||
)?
|
||||
} else {
|
||||
load_search_results_with_filters(
|
||||
state.config(),
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
&normalized_query,
|
||||
page_id,
|
||||
body.limit.unwrap_or(30),
|
||||
filters,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
@@ -170,7 +212,7 @@ pub async fn documents(
|
||||
Json(json!({
|
||||
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"recent": [],
|
||||
"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")),
|
||||
@@ -184,6 +226,138 @@ pub async fn documents(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn refresh_local_index(
|
||||
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(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let refreshed = local_search_index::refresh_local_search_index(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
)?;
|
||||
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_backlinks(
|
||||
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(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let backlinks = local_search_index::query_local_backlinks(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
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(
|
||||
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(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
|
||||
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,
|
||||
@@ -365,6 +539,7 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -390,6 +565,18 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -533,4 +720,227 @@ mod tests {
|
||||
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());
|
||||
|
||||
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!(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());
|
||||
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_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());
|
||||
|
||||
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 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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user