Implement Rust web sidebar title and tree interactions
This commit is contained in:
@@ -42,8 +42,8 @@ pub async fn next_ai_agent_run(
|
||||
trace_id: context.trace.trace_id,
|
||||
target: format!("{}/bridge", state.config().hermes_base_path),
|
||||
notes: vec![
|
||||
"当前保留 Next route 兼容边界,后续用于把 /api/ai-agent/run 收口到 Rust Web 层。",
|
||||
"此占位实现不复制业务裁决,只声明桥接目标与迁移方向。",
|
||||
"/api/ai-agent/run 是 legacy compat endpoint,canonical route 是 /api/hermes/bridge。",
|
||||
"结构化写入必须通过 Hermes/Rust bridge 再落到 page/tree/edge command。",
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,6 +46,25 @@ pub struct DocumentSaveRequest {
|
||||
pub block_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentTitleRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub title: String,
|
||||
pub command_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentOptionsRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub options: Value,
|
||||
pub command_name: Option<String>,
|
||||
}
|
||||
|
||||
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
|
||||
@@ -484,7 +503,7 @@ pub async fn save(
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "documents.save".into(),
|
||||
name: "page.body.save".into(),
|
||||
command_id: format!("document_save_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
@@ -518,6 +537,73 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let mut result = execute_runtime_command_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert("executedCommand".into(), json!("page.body.save"));
|
||||
map.insert("canonicalCommand".into(), json!("page.body.save"));
|
||||
map.insert("compatRoute".into(), json!("/api/documents/save"));
|
||||
}
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn title(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentTitleRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let title = body.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("title_required", "缺少有效页面标题")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "page.head.updateTitle".into(),
|
||||
command_id: format!("page_title_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"title": title,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page title update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.head.updateTitle".into())],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
@@ -525,7 +611,94 @@ pub async fn save(
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"meta": {
|
||||
"commandName": "page.head.updateTitle",
|
||||
"canonicalCommand": "page.head.updateTitle",
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn options(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentOptionsRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "page.layout.updateOptions".into(),
|
||||
command_id: format!("page_options_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"options": body.options,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page layout update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.layout.updateOptions".into())],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"meta": {
|
||||
"commandName": "page.layout.updateOptions",
|
||||
"canonicalCommand": "page.layout.updateOptions",
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -597,6 +770,14 @@ mod tests {
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateTitle": {
|
||||
"ok": true,
|
||||
"title": "服务端页面(改名)"
|
||||
},
|
||||
"documents:updateOptions": {
|
||||
"ok": true,
|
||||
"show_toc": false
|
||||
},
|
||||
"documents:updateContent": {
|
||||
"ok": true,
|
||||
"updated_at": "2026-04-18T09:45:00Z",
|
||||
@@ -691,7 +872,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_save_route_executes_documents_save_command() {
|
||||
async fn documents_save_route_executes_page_body_save_command() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -728,5 +909,71 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["revision"], 8);
|
||||
assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
|
||||
assert_eq!(payload["result"]["executedCommand"], "page.body.save");
|
||||
assert_eq!(payload["result"]["canonicalCommand"], "page.body.save");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_title_route_executes_page_head_update_title() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/title")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"title": "服务端页面(改名)",
|
||||
"commandName": "page.head.updateTitle"
|
||||
})
|
||||
.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"]["commandName"], "page.head.updateTitle");
|
||||
assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_options_route_executes_page_layout_update_options() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/options")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"options": {
|
||||
"showToc": false
|
||||
},
|
||||
"commandName": "page.layout.updateOptions"
|
||||
})
|
||||
.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"]["commandName"], "page.layout.updateOptions");
|
||||
assert_eq!(payload["meta"]["canonicalCommand"], "page.layout.updateOptions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +39,48 @@ pub async fn health(
|
||||
|
||||
pub async fn bridge_runtime(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(runtime_input): Json<RuntimeInput>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let session_id = format!("hermes_{}", context.trace.request_id);
|
||||
let runtime_input = match serde_json::from_value::<RuntimeInput>(payload.clone()) {
|
||||
Ok(runtime_input) => runtime_input,
|
||||
Err(_) => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_ai_bridge_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"bridge": "hermes_session",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"structuredWrite": {
|
||||
"owner": "rust-web-hermes",
|
||||
"allowedCommands": [
|
||||
"page.body.save",
|
||||
"tree.node.create",
|
||||
"kernel.edge.attach"
|
||||
]
|
||||
},
|
||||
"compatPayload": payload,
|
||||
})),
|
||||
));
|
||||
}
|
||||
};
|
||||
let payload = if runtime_input_requests_result(&runtime_input) {
|
||||
match execute_runtime_query(runtime_input) {
|
||||
Ok(result) => json!({
|
||||
"ok": true,
|
||||
"bridge": "hermes_runtime_result",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(),
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"result": result,
|
||||
}),
|
||||
Err(error) => {
|
||||
@@ -67,9 +99,12 @@ pub async fn bridge_runtime(
|
||||
json!({
|
||||
"ok": success.ok,
|
||||
"bridge": "hermes_runtime_plan",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(),
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"plan": success.plan,
|
||||
})
|
||||
}
|
||||
@@ -87,14 +122,18 @@ pub async fn bridge_runtime(
|
||||
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
|
||||
}
|
||||
|
||||
fn ai_bridge_contract() -> Value {
|
||||
fn ai_bridge_contract(session_id: &str) -> Value {
|
||||
json!({
|
||||
"schema": "mnote.ai_bridge.v1",
|
||||
"owner": "mnote-web",
|
||||
"bridge": "hermes",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{session_id}"),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"sessionOwner": "rust-web-hermes",
|
||||
"toolEventOwner": "rust-web-hermes",
|
||||
"clientActionOwner": "rust-web-hermes"
|
||||
"clientActionOwner": "rust-web-hermes",
|
||||
"structuredWriteOwner": "rust-web-hermes"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,5 +251,39 @@ mod tests {
|
||||
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert!(payload["eventStreamEndpoint"].as_str().unwrap_or_default().contains("/api/hermes/events/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/bridge")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"stream": true,
|
||||
"scope": "document",
|
||||
"messages": [{"role": "user", "content": "生成摘要"}],
|
||||
"context": {"documentId": "doc_1"}
|
||||
})
|
||||
.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["bridge"], "hermes_session");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert_eq!(payload["contract"]["structuredWriteOwner"], "rust-web-hermes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,15 @@ pub async fn mindmap_object_shell(
|
||||
"mindmapId": mindmap_id,
|
||||
"projection": {
|
||||
"schema": "mnote.mindmap_projection.v1",
|
||||
"source": "rust-web-object-shell"
|
||||
"source": "rust-kernel",
|
||||
"owner": "rust-kernel",
|
||||
"queryName": "mindmap.projection.get"
|
||||
},
|
||||
"island": {
|
||||
"kind": "react_mindmap_runtime",
|
||||
"mountId": "mnote-mindmap-island",
|
||||
"legacyCompat": "next-app-router"
|
||||
"runtimeRole": "renderer_adapter",
|
||||
"commandName": "mindmap.command.apply"
|
||||
},
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id
|
||||
@@ -145,6 +148,8 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("mnote.mindmap_shell.v1"));
|
||||
assert!(html.contains("data-react-island=\"mindmap_runtime\""));
|
||||
assert!(html.contains("rust-web-object-shell"));
|
||||
assert!(html.contains("mindmap.projection.get"));
|
||||
assert!(html.contains("mindmap.command.apply"));
|
||||
assert!(!html.contains("next-app-router"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
.route("/api/documents/options", post(documents::options))
|
||||
.route("/api/documents/save", post(documents::save))
|
||||
.route(
|
||||
"/api/documents/runtime/transform",
|
||||
|
||||
@@ -2,7 +2,8 @@ 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,
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||
use crate::ssr::pages::search::SearchPage;
|
||||
@@ -68,18 +69,30 @@ pub async fn shell(
|
||||
.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",
|
||||
"results": []
|
||||
"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": "react_search_palette",
|
||||
"kind": "search_interaction_island",
|
||||
"mountId": "mnote-search-island",
|
||||
"runtime": "SearchPaletteHost"
|
||||
},
|
||||
@@ -92,6 +105,7 @@ pub async fn shell(
|
||||
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!(
|
||||
@@ -122,7 +136,7 @@ pub async fn shell(
|
||||
}
|
||||
|
||||
pub async fn documents(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<SearchDocumentsRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
@@ -137,49 +151,16 @@ pub async fn documents(
|
||||
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 result = 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);
|
||||
@@ -188,10 +169,12 @@ pub async fn documents(
|
||||
headers,
|
||||
Json(json!({
|
||||
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"recent": [],
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
"queryName": "search.documents",
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"queryName": "search.documents.query",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
},
|
||||
@@ -199,10 +182,138 @@ pub async fn documents(
|
||||
))
|
||||
}
|
||||
|
||||
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_convex(config, context, Some(workspace_id), runtime_query.clone()).await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => execute_runtime_query_against_data(
|
||||
context,
|
||||
Some(workspace_id),
|
||||
runtime_query,
|
||||
fallback_search_dataset(workspace_id),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_search_projection() -> Value {
|
||||
json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
"results": [],
|
||||
})
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
],
|
||||
"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"));
|
||||
@@ -275,8 +386,10 @@ mod tests {
|
||||
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"));
|
||||
assert!(html.contains("search_interaction_island"));
|
||||
assert!(html.contains("search.documents.query"));
|
||||
assert!(html.contains("search-result"));
|
||||
assert!(html.contains("Rust Web 搜索结果"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -327,6 +440,8 @@ mod tests {
|
||||
.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");
|
||||
assert_eq!(payload["meta"]["queryName"], "search.documents.query");
|
||||
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
|
||||
assert_eq!(payload["projectionOwner"], "rust-kernel");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,19 @@ struct StreamPollState {
|
||||
}
|
||||
|
||||
fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
let event_id = payload
|
||||
.get("revision")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| value.as_u64().map(|number| number.to_string()))
|
||||
})
|
||||
.or_else(|| payload.get("cursor").and_then(Value::as_str).map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "0".into());
|
||||
Event::default()
|
||||
.event(event_name)
|
||||
.id(event_id)
|
||||
.json_data(payload)
|
||||
.expect("SSE 事件必须可序列化")
|
||||
}
|
||||
@@ -245,4 +256,25 @@ mod tests {
|
||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||
assert!(text.contains("\"kind\":\"snapshot\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_events_route_includes_event_id_and_revision() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=0")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("id: "));
|
||||
assert!(text.contains("\"revision\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +477,7 @@ pub fn build_stream_delta_payload(
|
||||
let scope = resolve_stream_scope(query);
|
||||
json!({
|
||||
"kind": "delta",
|
||||
"revision": cursor.clone().unwrap_or_else(|| "0".into()),
|
||||
"stream": scope.as_str(),
|
||||
"projection": scope.projection(),
|
||||
"requestId": context.trace.request_id,
|
||||
@@ -570,6 +571,7 @@ pub async fn load_stream_snapshot(
|
||||
|
||||
Ok(json!({
|
||||
"kind": "snapshot",
|
||||
"revision": cursor.clone().unwrap_or_else(|| "0".into()),
|
||||
"scope": scope.as_str(),
|
||||
"stream": scope.as_str(),
|
||||
"projection": scope.projection(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
||||
};
|
||||
@@ -24,9 +25,10 @@ use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use std::path::{Component, Path as FsPath, PathBuf};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
@@ -52,7 +54,7 @@ pub async fn document_page_shell(
|
||||
query.workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let title = aggregate.head_title();
|
||||
let title = aggregate.head.title.as_str();
|
||||
let workspace_id = aggregate.identity.workspace_id.clone();
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let mut workspace_projection = load_workspace_shell_projection(
|
||||
@@ -86,6 +88,7 @@ pub async fn document_page_shell(
|
||||
<DocumentPage
|
||||
title={title.to_string()}
|
||||
document_id={document_id.clone()}
|
||||
workspace_id={workspace_id.clone()}
|
||||
sidebar_tree_html={sidebar_tree_html}
|
||||
workspace_name={workspace_name}
|
||||
workspace_sidebar_html={workspace_sidebar_html}
|
||||
@@ -105,6 +108,7 @@ pub async fn document_page_shell(
|
||||
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(title),
|
||||
@@ -113,6 +117,7 @@ pub async fn document_page_shell(
|
||||
body_content,
|
||||
escape_script_json(&snapshot_json),
|
||||
escape_script_json(&bootstrap_json),
|
||||
render_document_title_controller_script(),
|
||||
render_editor_island_adapter_script(),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
@@ -128,6 +133,7 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
|
||||
"workspaceId": aggregate.identity.workspace_id,
|
||||
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
|
||||
"saveEndpoint": "/api/documents/save",
|
||||
"titleEndpoint": "/api/documents/title",
|
||||
"editorHostKind": "leptos_tiptap_island",
|
||||
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
|
||||
"requestId": context.trace.request_id,
|
||||
@@ -136,6 +142,123 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn render_document_title_controller_script() -> &'static str {
|
||||
r#"<script>
|
||||
(() => {
|
||||
const CONTRACT = 'mnote.document_title_controller.v1';
|
||||
const input = document.querySelector('[data-page-title-input="true"]');
|
||||
if (!(input instanceof HTMLTextAreaElement)) return;
|
||||
|
||||
input.setAttribute('data-title-controller', CONTRACT);
|
||||
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
|
||||
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
|
||||
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
|
||||
let lastSavedTitle = input.value.trim() || '无标题';
|
||||
let saving = false;
|
||||
|
||||
const cssEscape = (value) => {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
const autosize = () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
|
||||
};
|
||||
|
||||
const setStatus = (status, message) => {
|
||||
input.setAttribute('data-title-save-status', status);
|
||||
const shell = input.closest('.document-shell');
|
||||
if (shell instanceof HTMLElement) shell.setAttribute('data-title-save-status', status);
|
||||
if (message) input.setAttribute('data-title-save-error', message);
|
||||
else input.removeAttribute('data-title-save-error');
|
||||
};
|
||||
|
||||
const setText = (selector, title) => {
|
||||
document.querySelectorAll(selector).forEach((node) => {
|
||||
if (node instanceof HTMLElement) node.textContent = title;
|
||||
});
|
||||
};
|
||||
|
||||
const updateVisibleTitle = (title) => {
|
||||
document.title = title;
|
||||
setText('[data-page-title-current]', title);
|
||||
const current = document.querySelector('.wolai-breadcrumb-current');
|
||||
if (current instanceof HTMLElement) {
|
||||
let titleNode = current.querySelector('[data-page-title-current]');
|
||||
if (!(titleNode instanceof HTMLElement)) {
|
||||
titleNode = document.createElement('span');
|
||||
titleNode.setAttribute('data-page-title-current', 'true');
|
||||
current.appendChild(titleNode);
|
||||
}
|
||||
titleNode.textContent = title;
|
||||
}
|
||||
if (!documentId) return;
|
||||
const escapedId = cssEscape(documentId);
|
||||
setText(`[data-node-id="${escapedId}"] .tree-link-title`, title);
|
||||
setText(`[data-document-id="${escapedId}"] .tree-link-title`, title);
|
||||
setText(`[data-doc-id="${escapedId}"] .tree-link-title`, title);
|
||||
setText(`[data-node-id="${escapedId}"] .wolai-row-title`, title);
|
||||
setText(`a[href="/documents/${escapedId}"] .wolai-row-title`, title);
|
||||
setText(`a[href^="/documents/${escapedId}?"] .wolai-row-title`, title);
|
||||
};
|
||||
|
||||
const saveTitle = async () => {
|
||||
const title = input.value.trim() || '无标题';
|
||||
autosize();
|
||||
if (!documentId || saving || title === lastSavedTitle) {
|
||||
updateVisibleTitle(title);
|
||||
setStatus('saved');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
setStatus('saving');
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
workspaceId: workspaceId || null,
|
||||
title,
|
||||
commandName: 'page.head.updateTitle',
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
|
||||
}
|
||||
lastSavedTitle = title;
|
||||
updateVisibleTitle(title);
|
||||
setStatus('saved');
|
||||
window.dispatchEvent(new CustomEvent('tree:title-updated', {
|
||||
detail: { documentId, workspaceId: workspaceId || null, title, payload },
|
||||
}));
|
||||
} catch (error) {
|
||||
setStatus('error', error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
autosize();
|
||||
setStatus(input.value.trim() === lastSavedTitle ? 'saved' : 'dirty');
|
||||
});
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => { void saveTitle(); });
|
||||
autosize();
|
||||
updateVisibleTitle(lastSavedTitle);
|
||||
setStatus('saved');
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
|
||||
fn render_editor_island_adapter_script() -> &'static str {
|
||||
r#"<script type="module">
|
||||
(() => {
|
||||
@@ -441,6 +564,7 @@ pub async fn page_aggregate(
|
||||
query.workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let projection_owner = aggregate.source_label();
|
||||
let mut response = (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
@@ -454,6 +578,11 @@ pub async fn page_aggregate(
|
||||
)
|
||||
.into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "page-aggregate");
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
|
||||
if let Ok(value) = HeaderValue::from_str(projection_owner) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -482,145 +611,24 @@ async fn build_page_aggregate_snapshot(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let conflict_detection_key = content
|
||||
.get("conflictDetectionKey")
|
||||
.or_else(|| content.get("conflict_detection_key"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let page_subtree = content
|
||||
.get("pageSubtree")
|
||||
.or_else(|| content.get("page_subtree"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let todo_total = meta
|
||||
.get("todo_total")
|
||||
.or_else(|| meta.get("todo_total_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let todo_done = meta
|
||||
.get("todo_done")
|
||||
.or_else(|| meta.get("todo_done_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let projection = execute_runtime_query_against_data(
|
||||
context,
|
||||
workspace_id,
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "page.aggregate.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
},
|
||||
json!({
|
||||
"meta": meta,
|
||||
"content": content,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(PageAggregate::builder()
|
||||
// identity
|
||||
.document_id(
|
||||
meta.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(document_id),
|
||||
)
|
||||
.workspace_id(
|
||||
meta.get("workspace_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default"),
|
||||
)
|
||||
// head
|
||||
.title(
|
||||
meta.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("无标题"),
|
||||
)
|
||||
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
|
||||
.read_only(
|
||||
meta.get("can_edit")
|
||||
.and_then(Value::as_bool)
|
||||
.map(|can_edit| !can_edit)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.disable_download(
|
||||
meta.get("disable_download")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.disable_copy(
|
||||
meta.get("disable_copy")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
// layout
|
||||
.wide_layout(
|
||||
meta.get("wide_layout")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.small_text(
|
||||
meta.get("use_small_text")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_heading_numbers(
|
||||
meta.get("show_heading_numbers")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
)
|
||||
.show_toc(
|
||||
meta.get("show_toc")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_structure(
|
||||
meta.get("show_structure")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.protect_editing(
|
||||
meta.get("protect_editing")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_word_count(
|
||||
meta.get("show_word_count")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
)
|
||||
.collapse_backlinks(
|
||||
meta.get("collapse_backlinks")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.page_font(
|
||||
meta.get("page_font")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default"),
|
||||
)
|
||||
.layout_density(
|
||||
meta.get("layout_density")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("normal"),
|
||||
)
|
||||
.hide_child_pages(
|
||||
meta.get("hide_child_pages")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_block_ref_count(
|
||||
meta.get("show_block_ref_count")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.embed_default_block_id(
|
||||
meta.get("embed_default_block_id")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
)
|
||||
// body
|
||||
.content(content.get("content").cloned().unwrap_or(Value::Null))
|
||||
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
|
||||
.conflict_detection_key(conflict_detection_key)
|
||||
// tree
|
||||
.page_subtree(page_subtree)
|
||||
// stats
|
||||
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
|
||||
.character_count(
|
||||
meta.get("character_count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
|
||||
.todo_total(todo_total)
|
||||
.todo_done(todo_done)
|
||||
.build())
|
||||
serde_json::from_value::<PageAggregate>(projection)
|
||||
.map_err(|error| WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}")))
|
||||
}
|
||||
|
||||
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
||||
@@ -929,6 +937,15 @@ mod tests {
|
||||
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
|
||||
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
|
||||
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
||||
assert!(html.contains("aria-label=\"页面标题\""));
|
||||
assert!(html.contains("data-page-title-input=\"true\""));
|
||||
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
|
||||
assert!(html.contains("mnote.document_title_controller.v1"));
|
||||
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
|
||||
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
@@ -945,12 +962,21 @@ mod tests {
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-page-aggregate-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-kernel")
|
||||
);
|
||||
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["owner"], "mnote-web");
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(payload["result"]["source"], "KernelProjection");
|
||||
assert_eq!(payload["result"]["projectionVersion"], 1);
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user