Files
mnote/rust/crates/mnote-web/src/routes/documents.rs
T

1814 lines
66 KiB
Rust
Raw Normal View History

use crate::app::AppState;
use crate::context::RequestContext;
use crate::document_buffer_store::{self, BufferKey, BufferStore};
use crate::error::WebError;
2026-05-13 22:43:16 +08:00
use crate::routes::command_support::{
execute_runtime_command_via_legacy_cloud,
execute_runtime_command_via_legacy_cloud_with_artifacts,
2026-05-13 22:43:16 +08:00
};
2026-05-08 00:41:03 +08:00
use crate::routes::local_folder_source::{
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
2026-05-08 00:41:03 +08:00
};
use crate::routes::query_support::{
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
resolve_effective_workspace_id,
};
use axum::extract::{Extension, Query, State};
2026-04-29 12:24:44 +08:00
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
2026-05-29 11:13:05 +08:00
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
2026-05-29 11:13:05 +08:00
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentContentQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentMetaQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BufferStateQuery {
pub document_id: Option<String>,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub relative_path: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BufferDirtyRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub relative_path: Option<String>,
pub content_hash: Option<String>,
}
fn find_document_buffer_state(
buffer_store: &BufferStore,
query: &BufferStateQuery,
) -> Option<core_protocol::DocumentBuffer> {
if let (Some(root_uri), Some(_source_kind), Some(document_id)) = (
query.root_uri.as_deref().filter(|v| !v.is_empty()),
query.source_kind.as_deref().filter(|v| !v.is_empty()),
query.document_id.as_deref().filter(|v| !v.is_empty()),
) {
// 本地文件夹查询优先用明确 relativePath;缺省时退回扫描,避免前端只知道 documentId 时查不到已初始化 buffer。
if let Some(relative_path) = query.relative_path.as_deref().filter(|v| !v.is_empty()) {
let ws_path = document_buffer_store::build_local_folder_workspace_path(
query.workspace_id.as_deref().unwrap_or(""),
root_uri,
relative_path,
document_id,
);
if let Some(buffer) = buffer_store.get_by_path(&ws_path) {
return Some(buffer);
}
return buffer_store.all_buffers().into_iter().find(|buf| {
buf.workspace_path.root_uri == root_uri
&& buf.workspace_path.relative_path == relative_path
&& buf.workspace_path.object_identity.document_id.as_deref()
== Some(document_id)
});
}
return buffer_store.all_buffers().into_iter().find(|buf| {
buf.workspace_path.root_uri == root_uri
&& buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
});
}
if let (Some(document_id), Some(workspace_id), Some(relative_path)) = (
query.document_id.as_deref().filter(|v| !v.is_empty()),
query.workspace_id.as_deref().filter(|v| !v.is_empty()),
query.relative_path.as_deref().filter(|v| !v.is_empty()),
) {
// 通过完整 workspace path 精确查询。
let key = BufferKey::from_parts(
workspace_id,
"LocalFolder",
"",
relative_path,
Some(document_id.to_string()),
);
return buffer_store.get(&key);
}
if let Some(document_id) = query.document_id.as_deref().filter(|v| !v.is_empty()) {
// 最后按 documentId 扫描,作为调试和兼容查询兜底。
return buffer_store.all_buffers().into_iter().find(|buf| {
buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
});
}
None
}
fn buffer_state_payload(buf: core_protocol::DocumentBuffer) -> Value {
json!({
"documentId": buf.workspace_path.object_identity.document_id,
"workspaceId": buf.workspace_path.workspace_id,
"dirtyState": format!("{:?}", buf.dirty_state),
"fileVersion": buf.file_version,
"baseContentHash": buf.base_content_hash,
"currentContentHash": buf.current_content_hash,
"externalActor": buf.external_actor,
"lastLoadedAt": buf.last_loaded_at,
"lastSavedAt": buf.last_saved_at,
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSaveRequest {
pub document_id: String,
pub workspace_id: Option<String>,
2026-05-08 00:41:03 +08:00
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub expected_file_version: Option<String>,
pub write_intent_id: Option<String>,
pub save_operation_id: Option<String>,
pub base_content_hash: Option<String>,
pub content_format: Option<String>,
pub editor_source: Option<String>,
2026-04-21 06:26:35 +08:00
pub editor_document: Option<Value>,
pub content: Value,
2026-04-21 06:26:35 +08:00
pub tiptap_document: Option<Value>,
pub snapshot_captured_at: Option<String>,
pub block_count: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentTitleRequest {
pub document_id: String,
pub workspace_id: Option<String>,
2026-05-08 00:41:03 +08:00
pub source_kind: Option<String>,
pub root_uri: 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>,
2026-05-08 00:41:03 +08:00
pub source_kind: Option<String>,
pub root_uri: Option<String>,
#[serde(default)]
pub options: Value,
pub command_name: Option<String>,
}
2026-05-06 21:44:20 +08:00
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentPurgeRequest {
pub document_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentEmptyTrashRequest {
pub workspace_id: String,
}
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
2026-04-29 12:24:44 +08:00
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
2026-04-29 12:24:44 +08:00
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
(
StatusCode::OK,
2026-04-29 12:24:44 +08:00
headers,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
2026-04-29 12:24:44 +08:00
"owner": "mnote-web",
"result": result,
})),
)
}
fn execution_artifacts_json(
execution: &crate::transport::convex::RetiredCloudCommandExecution,
) -> Value {
execution
.artifacts
.as_ref()
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
.unwrap_or(Value::Null)
}
2026-04-29 12:24:44 +08:00
fn stamp_documents_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_DOCUMENTS_TRANSPORT.as_bytes()) {
headers.insert(name, HeaderValue::from_static("documents-api"));
}
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
let trimmed = v.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
fn next_documents_base_url() -> String {
read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV)
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
.trim()
.trim_end_matches('/')
.to_string()
}
2026-04-29 12:24:44 +08:00
fn should_proxy_via_next(_context: &RequestContext) -> bool {
false
}
fn build_next_proxy_headers(
context: &RequestContext,
effective_workspace_id: Option<&str>,
) -> reqwest::header::HeaderMap {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) {
let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else {
return;
};
let Ok(header_value) = HeaderValue::from_str(value) else {
return;
};
headers.insert(header_name, header_value);
}
let mut headers = HeaderMap::new();
if let Some(cookie) = context.auth.cookie_header.as_deref() {
insert(&mut headers, "cookie", cookie);
}
if let Some(authorization) = context.auth.authorization.as_deref() {
insert(&mut headers, "authorization", authorization);
}
insert(&mut headers, "x-request-id", &context.trace.request_id);
insert(&mut headers, "x-trace-id", &context.trace.trace_id);
insert(
&mut headers,
"x-mnote-source-channel",
&context.source.channel,
);
insert(
&mut headers,
"x-mnote-source-client",
&context.source.client,
);
insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id);
insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type);
if let Some(session_id) = context.auth.session_id.as_deref() {
insert(&mut headers, "x-mnote-session-id", session_id);
}
if let Some(workspace_id) = effective_workspace_id {
insert(&mut headers, "x-mnote-workspace-id", workspace_id);
}
headers
}
async fn send_next_documents_request(
context: &RequestContext,
request: reqwest::RequestBuilder,
phase: &'static str,
) -> Result<Value, WebError> {
let response = request.send().await.map_err(|error| {
let base = if error.is_timeout() {
WebError::gateway_timeout_code(
"next_proxy_timeout",
format!("Next compat 请求超时: {error}"),
)
} else {
WebError::service_unavailable_code(
"next_proxy_unavailable",
format!("Next compat 请求失败: {error}"),
)
};
base.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"next_proxy_bad_response",
format!("Next compat 响应读取失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
let payload = serde_json::from_str::<Value>(&text).map_err(|_| {
let snippet: String = text.chars().take(180).collect();
WebError::bad_gateway_code(
"next_proxy_bad_response",
format!("Next compat 返回了非 JSON 内容: {snippet}"),
)
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
if !status.is_success() {
let message = payload
.get("error")
.and_then(Value::as_str)
.or_else(|| payload.get("message").and_then(Value::as_str))
.unwrap_or("Next compat 文档接口请求失败");
let web_error = match status {
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message)
}
reqwest::StatusCode::NOT_FOUND => {
WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message)
}
_ => WebError::bad_gateway_code("next_proxy_error", message),
};
return Err(web_error
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string()));
}
Ok(payload)
}
async fn proxy_next_documents_meta(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let mut url =
reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| {
WebError::internal(format!("Next compat meta URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_meta_url")
.with_header("x-upstream-service", "next")
})?;
url.query_pairs_mut().append_pair("documentId", document_id);
if let Some(workspace_id) = effective_workspace_id {
url.query_pairs_mut()
.append_pair("workspaceId", workspace_id);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_meta_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.get(url)
.headers(build_next_proxy_headers(context, effective_workspace_id)),
"next_proxy_meta",
)
.await?;
Ok(payload.get("doc").cloned().unwrap_or(Value::Null))
}
async fn proxy_next_documents_content(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let mut url =
reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| {
WebError::internal(format!("Next compat content URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_content_url")
.with_header("x-upstream-service", "next")
})?;
url.query_pairs_mut().append_pair("documentId", document_id);
if let Some(workspace_id) = effective_workspace_id {
url.query_pairs_mut()
.append_pair("workspaceId", workspace_id);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_content_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.get(url)
.headers(build_next_proxy_headers(context, effective_workspace_id)),
"next_proxy_content",
)
.await?;
Ok(json!({
"content": payload.get("content").cloned().unwrap_or(Value::Null),
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
"pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null),
}))
}
async fn proxy_next_documents_save(
context: &RequestContext,
effective_workspace_id: Option<&str>,
body: &DocumentSaveRequest,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| {
WebError::internal(format!("Next compat save URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_save_url")
.with_header("x-upstream-service", "next")
})?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_save_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.post(url)
.headers(build_next_proxy_headers(context, effective_workspace_id))
.json(&json!({
"documentId": body.document_id,
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version,
"writeIntentId": body.write_intent_id,
"saveOperationId": body.save_operation_id,
2026-04-21 06:26:35 +08:00
"editorDocument": body.editor_document,
"content": body.content,
2026-04-21 06:26:35 +08:00
"tiptapDocument": body.tiptap_document,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
})),
"next_proxy_save",
)
.await?;
Ok(json!({
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": payload
.get("conflictDetectionKey")
.cloned()
.unwrap_or(Value::Null),
"ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)),
}))
}
2026-04-29 12:24:44 +08:00
pub async fn load_document_meta_result(
state: &AppState,
context: &RequestContext,
query: DocumentMetaQuery,
) -> Result<Value, WebError> {
let document_id_owned = query.document_id.trim().to_string();
if document_id_owned.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(context) {
return proxy_next_documents_meta(
context,
effective_workspace_id.as_deref(),
&document_id_owned,
)
.await;
}
fetch_documents_meta_via_legacy_cloud(
2026-04-29 12:24:44 +08:00
state.config(),
context,
effective_workspace_id.as_deref(),
&document_id_owned,
)
.await
}
pub async fn load_document_content_result(
state: &AppState,
context: &RequestContext,
query: DocumentContentQuery,
) -> Result<Value, WebError> {
let document_id_owned = query.document_id.trim().to_string();
if document_id_owned.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(context) {
return proxy_next_documents_content(
context,
effective_workspace_id.as_deref(),
&document_id_owned,
)
.await;
}
execute_runtime_query_via_legacy_cloud(
2026-04-29 12:24:44 +08:00
state.config(),
context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "documents.content.get".into(),
payload: json!({
"documentId": document_id_owned,
"workspaceId": effective_workspace_id,
}),
},
)
.await
}
pub async fn meta(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>,
2026-04-29 12:24:44 +08:00
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let result = load_document_meta_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
pub async fn content(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>,
2026-04-29 12:24:44 +08:00
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let result = load_document_content_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
/// 查询指定文档的当前 BufferStore 状态。
///
/// 支持两种查询方式:
/// 1. 通过 documentId + sourceKind + rootUri 自动构造 workspace path
/// 2. 通过 documentId + workspaceId + relativePath 精确查询
pub async fn buffer_state(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<BufferStateQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let result = find_document_buffer_state(&state.buffer_store, &query);
match result {
Some(buf) => Ok(ok_response(&context, buffer_state_payload(buf))),
None => Err(WebError::new(
StatusCode::NOT_FOUND,
"buffer_state_not_found",
"未找到该文档的 buffer 状态",
)
.with_context(&context)),
}
}
/// 标记指定打开文档的 BufferStore 状态为 Dirty。
///
/// 浏览器仍负责具体保存调度;Rust BufferStore 只承担跨 watcher、AI、保存冲突的统一打开态仲裁。
pub async fn mark_buffer_dirty(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<BufferDirtyRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(WebError::new(
StatusCode::BAD_REQUEST,
"document_id_required",
"documentId 不能为空",
)
.with_context(&context));
}
let query = BufferStateQuery {
document_id: Some(document_id.to_string()),
workspace_id: body.workspace_id.clone(),
source_kind: body.source_kind.clone(),
root_uri: body.root_uri.clone(),
relative_path: body.relative_path.clone(),
};
let existing = find_document_buffer_state(&state.buffer_store, &query).ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"buffer_state_not_found",
"未找到该文档的 buffer 状态",
)
.with_context(&context)
})?;
let content_hash = body
.content_hash
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("browser-dirty")
.to_string();
let marked = state
.buffer_store
.mark_dirty(&existing.workspace_path, content_hash)
.unwrap_or(existing);
Ok(ok_response(&context, buffer_state_payload(marked)))
}
pub async fn page_body_write(
2026-05-20 10:43:38 +08:00
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<core_protocol::PageBodyWriteRequest>,
) -> 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),
);
}
if body.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder {
return Err(WebError::bad_request_code(
"page_body_write_source_unsupported",
"page.body.write 当前只支持 local_folder 本地写入",
)
.with_context(&context));
}
let root_uri = body.root_uri.trim();
if root_uri.is_empty() {
return Err(WebError::bad_request_code(
"local_folder_root_required",
"缺少本地文件夹 rootUri",
)
.with_context(&context));
}
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
2026-05-20 10:43:38 +08:00
let result = write_local_markdown_page_body(&body, Some(&state.buffer_store))?;
Ok(ok_response(&context, result))
}
pub async fn save(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>,
2026-04-29 12:24:44 +08:00
) -> 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),
);
}
2026-05-08 00:41:03 +08:00
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_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let expected_file_version = body
.expected_file_version
.as_deref()
.or(body.conflict_detection_key.as_deref());
2026-05-20 10:43:38 +08:00
let result = write_local_markdown_page_body(
&core_protocol::PageBodyWriteRequest {
document_id: document_id.to_string(),
workspace_id: body.workspace_id.clone().unwrap_or_default(),
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(),
expected_file_version: expected_file_version.map(ToOwned::to_owned),
write_intent_id: body.write_intent_id.clone(),
save_operation_id: body.save_operation_id.clone(),
2026-05-20 10:43:38 +08:00
base_content_hash: body.base_content_hash.clone(),
content_format: body
.content_format
.clone()
.unwrap_or_else(|| "editorBlocks".into()),
content: body.content.clone(),
editor_source: body
.editor_source
.clone()
.or_else(|| Some("documents/save-compat".into())),
},
Some(&state.buffer_store),
)?;
2026-05-08 00:41:03 +08:00
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?;
return Ok(ok_response(&context, result));
}
let command = RuntimeCommandEnvelopeWire {
name: "page.body.save".into(),
command_id: format!("document_save_{}", 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(),
2026-05-08 00:41:03 +08:00
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
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,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version,
"writeIntentId": body.write_intent_id,
"saveOperationId": body.save_operation_id,
2026-04-21 06:26:35 +08:00
"editorDocument": body.editor_document,
"content": body.content,
2026-04-21 06:26:35 +08:00
"tiptapDocument": body.tiptap_document,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
}),
2026-04-26 04:29:23 +08:00
preflight_data: None,
reason: Some("mnote-web human editor save".into()),
refs: vec!["mnote-web-editor-runtime".into()],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
2026-05-13 22:43:16 +08:00
let mut result = execution.result;
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"));
2026-05-13 22:43:16 +08:00
if let Some(artifact_error) = execution.artifact_error {
map.insert("artifactError".into(), json!(artifact_error));
}
}
Ok(ok_response(&context, result))
}
2026-05-06 21:44:20 +08:00
pub async fn purge(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentPurgeRequest>,
) -> 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 command = RuntimeCommandEnvelopeWire {
name: "documents.purge".into(),
command_id: format!("document_purge_{}", 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(),
2026-05-08 00:41:03 +08:00
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
2026-05-06 21:44:20 +08:00
},
target: Some(RuntimeTargetWire {
workspace_id: None,
page_id: Some(document_id.to_string()),
block_id: None,
}),
payload: json!({
"documentId": document_id,
}),
preflight_data: None,
reason: Some("mnote-web documents purge compat".into()),
refs: vec!["mnote-web-documents-compat".into()],
dry_run: false,
validate_only: false,
};
let result =
execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?;
2026-05-06 21:44:20 +08:00
Ok(ok_response(&context, result))
}
pub async fn empty_trash(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentEmptyTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let workspace_id = body.workspace_id.trim();
if workspace_id.is_empty() {
return Err(
WebError::bad_request_code("workspace_id_required", "缺少有效 workspaceId")
.with_context(&context),
);
}
let command = RuntimeCommandEnvelopeWire {
name: "tree.trash.emptyWorkspace".into(),
command_id: format!("tree_trash_empty_workspace_{}", 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(),
source_kind: None,
root_uri: None,
workspace_id: Some(workspace_id.to_string()),
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some(workspace_id.to_string()),
page_id: None,
block_id: None,
}),
payload: json!({
"workspaceId": workspace_id,
}),
preflight_data: None,
reason: Some("mnote-web tree trash empty workspace".into()),
refs: vec![
"mnote-web-documents-trash-compat".into(),
"tree.trash.emptyWorkspace".into(),
],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
Some(workspace_id),
command,
)
.await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let mut result = execution.result;
if let Value::Object(map) = &mut result {
map.insert(
"canonicalCommand".into(),
json!("tree.trash.emptyWorkspace"),
);
map.insert("compatRoute".into(), json!("/api/documents/empty-trash"));
}
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": "tree.trash.emptyWorkspace",
"canonicalCommand": "tree.trash.emptyWorkspace",
"compatCommandName": "documents.emptyTrashByWorkspace",
"artifacts": artifacts,
"artifactError": artifact_error,
},
"result": 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(
2026-04-30 06:58:17 +08:00
WebError::bad_request_code("title_required", "缺少有效页面标题").with_context(&context),
);
}
2026-05-08 00:41:03 +08:00
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_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
2026-05-08 00:41:03 +08:00
let result = update_local_markdown_title(root_uri, document_id, title)?;
return Ok(ok_response(&context, result));
}
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(),
2026-05-08 00:41:03 +08:00
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
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()),
2026-05-29 11:13:05 +08:00
refs: vec![body
.command_name
.unwrap_or_else(|| "page.head.updateTitle".into())],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let result = execution.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",
"artifacts": artifacts,
"artifactError": artifact_error,
},
"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),
);
}
2026-05-08 00:41:03 +08:00
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_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let result = crate::routes::ui_preferences::update_page_preferences_from_value(
&state,
&context,
context.auth.actor_id.trim(),
body.workspace_id.as_deref().unwrap_or_default(),
body.source_kind.as_deref().unwrap_or("local_folder"),
root_uri,
document_id,
&body.options,
)?;
2026-05-08 00:41:03 +08:00
return Ok(ok_response(&context, result));
}
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(),
2026-05-08 00:41:03 +08:00
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
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()),
2026-05-29 11:13:05 +08:00
refs: vec![body
.command_name
.unwrap_or_else(|| "page.layout.updateOptions".into())],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let result = execution.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.layout.updateOptions",
"canonicalCommand": "page.layout.updateOptions",
"artifacts": artifacts,
"artifactError": artifact_error,
},
"result": result,
})),
))
}
#[cfg(test)]
mod tests {
2026-05-29 11:13:05 +08:00
use crate::app::{build_app, AppConfig, AppState};
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
use axum::body::{to_bytes, Body};
2026-04-29 12:24:44 +08:00
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde_json::Value;
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
2026-04-29 12:24:44 +08:00
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: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"updated_at": "2026-04-18T09:30:00Z",
"can_edit": true,
"disable_download": false,
"disable_copy": false,
"wide_layout": false,
"use_small_text": false,
"show_heading_numbers": true,
"show_toc": true,
"show_structure": true,
"protect_editing": false,
"show_word_count": true,
"collapse_backlinks": false,
"page_font": "default",
"layout_density": "normal",
"hide_child_pages": false,
"show_block_ref_count": true,
"embed_default_block_id": "heading_1",
"word_count": 42,
"character_count": 128,
"block_count": 3,
"todo_total": 1,
"todo_done": 0
},
"documents:getContent": {
"title": "服务端页面",
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一" }]
}
],
"revision": 7,
"conflict_detection_key": "doc_1:7"
}
}"#
.into(),
),
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",
"revision": 8,
"conflict_detection_key": "doc_1:8"
},
"documents:emptyTrashByWorkspace": {
"ok": true,
"deletedCount": 2
},
"bridgeLogs:recordCommandLog": {
"ok": true,
"id": "clog_fixture"
},
"bridgeLogs:recordDomainEvent": {
"ok": true,
"id": "evt_fixture"
}
}"#
.into(),
),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[test]
fn documents_buffer_state_falls_back_to_document_id_without_relative_path() {
let store = BufferStore::new();
let ws_path = build_local_folder_workspace_path(
"ws_local",
"file:///tmp/mnote-root",
"docs/page.md",
"local-md:docs~2Fpage.md",
);
store.init_buffer(&ws_path, Some("version-1".into()), Some("hash-1".into()));
let query = super::BufferStateQuery {
document_id: Some("local-md:docs~2Fpage.md".into()),
workspace_id: Some("ws_local".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote-root".into()),
relative_path: None,
};
let result = super::find_document_buffer_state(&store, &query).expect("buffer state");
assert_eq!(result.file_version.as_deref(), Some("version-1"));
assert_eq!(result.workspace_path.relative_path, "docs/page.md");
}
2026-04-29 12:24:44 +08:00
#[test]
fn convex_auth_cookie_does_not_force_next_proxy() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"),
);
let context = crate::context::RequestContext::from_http_parts(
&Method::GET,
&"/api/documents/meta".parse::<Uri>().expect("uri"),
&headers,
);
assert!(!super::should_proxy_via_next(&context));
}
#[tokio::test]
2026-04-29 12:24:44 +08:00
async fn documents_api_meta_route_returns_document_metadata() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/documents/meta?documentId=doc_1&workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
2026-04-29 12:24:44 +08:00
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-documents-transport")
.and_then(|value| value.to_str().ok()),
Some("documents-api")
);
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["result"]["id"], "doc_1");
assert_eq!(payload["result"]["workspace_id"], "ws_demo");
assert_eq!(payload["result"]["title"], "服务端页面");
assert_eq!(payload["result"]["show_structure"], true);
}
#[tokio::test]
2026-04-29 12:24:44 +08:00
async fn documents_api_content_route_returns_page_subtree() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/documents/content?documentId=doc_1")
.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 payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["revision"], 7);
assert_eq!(payload["result"]["pageSubtree"]["rootNodeId"], "doc_1");
assert_eq!(
payload["result"]["pageSubtree"]["outline"][0]["title"],
"章节一"
);
}
#[tokio::test]
async fn documents_save_route_executes_page_body_save_command() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": "doc_1",
"workspaceId": "ws_demo",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一(已编辑)" }]
}
],
"blockCount": 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["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_empty_trash_route_executes_workspace_trash_purge() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/empty-trash")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"workspaceId": "ws_demo"
})
.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["result"]["deletedCount"], 2);
assert_eq!(
payload["result"]["canonicalCommand"],
"tree.trash.emptyWorkspace"
);
assert_eq!(
payload["meta"]["artifacts"]["commandLog"]["commandName"],
"tree.trash.emptyWorkspace"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
"tree.trash.documents.emptied"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
"resync_required"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
}
#[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");
assert_eq!(
payload["meta"]["artifacts"]["commandLog"]["commandName"],
"page.head.updateTitle"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
"tree.node.renamed"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
"upsert_document"
);
assert_eq!(
2026-05-29 11:13:05 +08:00
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
["title"],
"服务端页面(改名)"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
}
#[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");
2026-04-30 06:58:17 +08:00
assert_eq!(
payload["meta"]["canonicalCommand"],
"page.layout.updateOptions"
);
assert_eq!(
payload["meta"]["artifacts"]["commandLog"]["commandName"],
"page.layout.updateOptions"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
"page.layout.options_updated"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
"resync_required"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
}
2026-05-08 00:41:03 +08:00
#[tokio::test]
async fn local_folder_documents_save_title_and_options_store_ui_preferences_in_sqlite() {
2026-05-08 00:41:03 +08:00
let root = std::env::temp_dir().join(format!(
"mnote-local-documents-write-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
2026-05-20 10:43:38 +08:00
std::fs::create_dir_all(root.join("Old Local Title")).expect("create local page bundle");
2026-05-08 00:41:03 +08:00
std::fs::write(
2026-05-20 10:43:38 +08:00
root.join("Old Local Title").join("Old Local Title.md"),
"# Old\n",
2026-05-08 00:41:03 +08:00
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_test",
&root_uri,
)
.expect("init local workspace");
2026-05-20 10:43:38 +08:00
let document_id = "local-md:Old~20Local~20Title~2FOld~20Local~20Title.md";
2026-05-08 00:41:03 +08:00
let title_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/title")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
2026-05-08 00:41:03 +08:00
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"title": "New Local Title"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("title response");
assert_eq!(title_response.status(), StatusCode::OK);
2026-05-20 10:43:38 +08:00
let title_body = to_bytes(title_response.into_body(), usize::MAX)
.await
.expect("title body");
let title_payload: Value = serde_json::from_slice(&title_body).expect("title json");
let renamed_document_id = title_payload["result"]["documentId"]
.as_str()
.expect("renamed document id")
.to_string();
2026-05-08 00:41:03 +08:00
let save_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
2026-05-08 00:41:03 +08:00
.body(Body::from(
serde_json::json!({
2026-05-20 10:43:38 +08:00
"documentId": renamed_document_id,
2026-05-08 00:41:03 +08:00
"sourceKind": "local_folder",
"rootUri": root_uri,
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 2 },
"content": [{ "type": "text", "text": "Saved Heading" }]
},
{
"id": "paragraph_1",
"type": "paragraph",
"content": [{ "type": "text", "text": "Saved body" }]
}
],
"blockCount": 2
})
.to_string(),
))
.expect("request"),
)
.await
.expect("save response");
assert_eq!(save_response.status(), StatusCode::OK);
let save_body = to_bytes(save_response.into_body(), usize::MAX)
.await
.expect("save body");
let save_payload: Value = serde_json::from_slice(&save_body).expect("save json");
assert_eq!(
save_payload["result"]["canonicalCommand"],
"page.body.write"
);
assert_eq!(save_payload["result"]["compatCommand"], "page.body.save");
2026-05-08 00:41:03 +08:00
let options_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/options")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
2026-05-08 00:41:03 +08:00
.body(Body::from(
serde_json::json!({
2026-05-20 10:43:38 +08:00
"documentId": renamed_document_id,
2026-05-08 00:41:03 +08:00
"sourceKind": "local_folder",
"rootUri": root_uri,
"options": {
"wideLayout": true,
"showToc": false,
"hideTitleHeader": false
2026-05-08 00:41:03 +08:00
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("options response");
assert_eq!(options_response.status(), StatusCode::OK);
2026-05-20 10:43:38 +08:00
let markdown =
std::fs::read_to_string(root.join("New Local Title").join("New Local Title.md"))
.expect("read md");
2026-05-08 00:41:03 +08:00
assert!(markdown.contains("## Saved Heading"));
assert!(markdown.contains("Saved body"));
assert!(
!root.join(".mnote").join("page-options.json").exists(),
"local folder UI 偏好不应继续写入 .mnote/page-options.json"
);
2026-05-08 00:41:03 +08:00
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_folder_documents_save_rejects_stale_expected_file_version() {
let root = std::env::temp_dir().join(format!(
"mnote-local-documents-expected-file-version-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(
root.join("README.md"),
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_test",
&root_uri,
)
.expect("init local workspace");
2026-05-20 10:43:38 +08:00
let document_id = "local-md:README.md";
let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate(
&root_uri,
document_id,
)
.expect("aggregate");
let stale_file_version = aggregate
.body
.conflict_detection_key
.as_str()
.expect("file version")
.to_string();
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(
root.join("README.md"),
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# External\n",
)
.expect("external write");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"expectedFileVersion": stale_file_version,
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "Editor" }]
}
],
"blockCount": 1
})
.to_string(),
))
.expect("request"),
)
.await
.expect("save response");
assert_eq!(response.status(), StatusCode::CONFLICT);
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["code"], "local_markdown_external_change");
assert_eq!(
payload["details"]["conflict"]["documentId"].as_str(),
Some(document_id)
);
assert_eq!(
payload["details"]["conflict"]["rootUri"].as_str(),
Some(root_uri.as_str())
);
assert_eq!(
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
Some(stale_file_version.as_str())
);
2026-05-29 11:13:05 +08:00
assert!(payload["details"]["conflict"]["currentDiskVersion"]
.as_str()
.unwrap_or("")
.starts_with("local-md:local-md:README.md:"));
assert!(payload["details"]["conflict"]["suggestedActions"]
.as_array()
.unwrap()
.iter()
.any(|action| action.as_str() == Some("merge")));
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("# External"));
assert!(!markdown.contains("# Editor"));
let _ = std::fs::remove_dir_all(&root);
}
}