提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。 不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
1409 lines
50 KiB
Rust
1409 lines
50 KiB
Rust
use crate::app::AppState;
|
|
use crate::context::RequestContext;
|
|
use crate::error::WebError;
|
|
use crate::routes::command_support::{
|
|
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
|
};
|
|
use crate::routes::local_folder_source::{
|
|
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
|
|
};
|
|
use crate::routes::query_support::{
|
|
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
|
resolve_effective_workspace_id,
|
|
};
|
|
use axum::extract::{Extension, Query, State};
|
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
use axum::Json;
|
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
|
use bridge_runtime::{
|
|
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
|
};
|
|
use serde::Deserialize;
|
|
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 DocumentSaveRequest {
|
|
pub document_id: String,
|
|
pub workspace_id: Option<String>,
|
|
pub source_kind: Option<String>,
|
|
pub root_uri: Option<String>,
|
|
pub revision: Option<u64>,
|
|
pub conflict_detection_key: Option<String>,
|
|
pub editor_document: Option<Value>,
|
|
pub content: Value,
|
|
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>,
|
|
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>,
|
|
pub source_kind: Option<String>,
|
|
pub root_uri: Option<String>,
|
|
#[serde(default)]
|
|
pub options: Value,
|
|
pub command_name: Option<String>,
|
|
}
|
|
|
|
#[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";
|
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
|
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
|
|
|
|
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
|
|
let mut headers = HeaderMap::new();
|
|
stamp_documents_headers(&mut headers);
|
|
(
|
|
StatusCode::OK,
|
|
headers,
|
|
Json(json!({
|
|
"ok": true,
|
|
"requestId": context.trace.request_id,
|
|
"traceId": context.trace.trace_id,
|
|
"owner": "mnote-web",
|
|
"result": result,
|
|
})),
|
|
)
|
|
}
|
|
|
|
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
|
execution
|
|
.artifacts
|
|
.as_ref()
|
|
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
|
.unwrap_or(Value::Null)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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,
|
|
"editorDocument": body.editor_document,
|
|
"content": body.content,
|
|
"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)),
|
|
}))
|
|
}
|
|
|
|
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_convex(
|
|
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_convex(
|
|
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>,
|
|
) -> 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>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
let result = load_document_content_result(&state, &context, query).await?;
|
|
Ok(ok_response(&context, result))
|
|
}
|
|
|
|
pub async fn save(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Json(body): Json<DocumentSaveRequest>,
|
|
) -> 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.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)
|
|
})?;
|
|
let result = save_local_markdown_page(
|
|
root_uri,
|
|
document_id,
|
|
body.conflict_detection_key.as_deref(),
|
|
&body.content,
|
|
)?;
|
|
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(),
|
|
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,
|
|
"editorDocument": body.editor_document,
|
|
"content": body.content,
|
|
"tiptapDocument": body.tiptap_document,
|
|
"snapshotCapturedAt": body.snapshot_captured_at,
|
|
"blockCount": body.block_count,
|
|
}),
|
|
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_convex_with_artifacts(
|
|
state.config(),
|
|
&context,
|
|
effective_workspace_id.as_deref(),
|
|
command,
|
|
)
|
|
.await?;
|
|
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"));
|
|
if let Some(artifact_error) = execution.artifact_error {
|
|
map.insert("artifactError".into(), json!(artifact_error));
|
|
}
|
|
}
|
|
Ok(ok_response(&context, result))
|
|
}
|
|
|
|
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(),
|
|
source_kind: None,
|
|
root_uri: None,
|
|
workspace_id: None,
|
|
capabilities: Vec::new(),
|
|
},
|
|
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_convex(state.config(), &context, None, command).await?;
|
|
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: "documents.emptyTrashByWorkspace".into(),
|
|
command_id: format!("documents_empty_trash_{}", 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 documents empty trash".into()),
|
|
refs: vec!["mnote-web-documents-trash".into()],
|
|
dry_run: false,
|
|
validate_only: false,
|
|
};
|
|
let execution = execute_runtime_command_via_convex_with_artifacts(
|
|
state.config(),
|
|
&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!("documents.emptyTrashByWorkspace"),
|
|
);
|
|
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": "documents.emptyTrashByWorkspace",
|
|
"canonicalCommand": "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(
|
|
WebError::bad_request_code("title_required", "缺少有效页面标题").with_context(&context),
|
|
);
|
|
}
|
|
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)
|
|
})?;
|
|
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(),
|
|
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()),
|
|
refs: vec![body
|
|
.command_name
|
|
.unwrap_or_else(|| "page.head.updateTitle".into())],
|
|
dry_run: false,
|
|
validate_only: false,
|
|
};
|
|
let execution = execute_runtime_command_via_convex_with_artifacts(
|
|
state.config(),
|
|
&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),
|
|
);
|
|
}
|
|
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)
|
|
})?;
|
|
let result = update_local_page_options(root_uri, document_id, &body.options)?;
|
|
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(),
|
|
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()),
|
|
refs: vec![body
|
|
.command_name
|
|
.unwrap_or_else(|| "page.layout.updateOptions".into())],
|
|
dry_run: false,
|
|
validate_only: false,
|
|
};
|
|
let execution = execute_runtime_command_via_convex_with_artifacts(
|
|
state.config(),
|
|
&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 {
|
|
use crate::app::{build_app, AppConfig, AppState};
|
|
use axum::body::{to_bytes, Body};
|
|
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(),
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
enable_legacy_next_compat: true,
|
|
enable_debug_shell_routes: false,
|
|
hermes_base_path: "/api/hermes".into(),
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
convex_url: None,
|
|
convex_admin_key: None,
|
|
allow_dev_fixtures: true,
|
|
query_fixtures_json: 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 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]
|
|
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);
|
|
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]
|
|
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"],
|
|
"documents.emptyTrashByWorkspace"
|
|
);
|
|
assert_eq!(
|
|
payload["meta"]["artifacts"]["commandLog"]["commandName"],
|
|
"documents.emptyTrashByWorkspace"
|
|
);
|
|
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!(
|
|
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");
|
|
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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_folder_documents_save_title_and_options_write_to_disk() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-local-documents-write-{}",
|
|
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: local-stable\ntitle: Old Title\n---\n# Old\n",
|
|
)
|
|
.expect("write md");
|
|
let root_uri = format!("file://{}", root.display());
|
|
let document_id = "local-mdid:local-stable";
|
|
|
|
let title_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/documents/title")
|
|
.header("content-type", "application/json")
|
|
.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);
|
|
|
|
let save_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/documents/save")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::json!({
|
|
"documentId": document_id,
|
|
"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 options_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/documents/options")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::json!({
|
|
"documentId": document_id,
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"options": {
|
|
"wideLayout": true,
|
|
"showToc": false
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("options response");
|
|
assert_eq!(options_response.status(), StatusCode::OK);
|
|
|
|
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
|
assert!(markdown.contains("mnote_id: local-stable"));
|
|
assert!(markdown.contains("title: New Local Title"));
|
|
assert!(markdown.contains("## Saved Heading"));
|
|
assert!(markdown.contains("Saved body"));
|
|
|
|
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
|
|
.expect("read page options");
|
|
let options_json: Value = serde_json::from_str(&options).expect("options json");
|
|
assert_eq!(options_json["pages"][document_id]["wideLayout"], true);
|
|
assert_eq!(options_json["pages"][document_id]["showToc"], false);
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
}
|