feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -2,14 +2,14 @@ use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -79,7 +79,7 @@ async fn execute_bridge_query(
|
||||
workspace_id: &str,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_runtime_query_via_convex(config, context, Some(workspace_id), query).await
|
||||
execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await
|
||||
}
|
||||
|
||||
pub async fn workspace(
|
||||
@@ -152,7 +152,7 @@ pub async fn trace(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2,15 +2,16 @@ use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::{
|
||||
execute_convex_command_plan, execute_convex_command_plan_with_artifacts, ConvexCommandExecution,
|
||||
RetiredCloudCommandExecution, execute_retired_command_plan,
|
||||
execute_retired_command_plan_with_artifacts,
|
||||
};
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||
RuntimeTargetWire,
|
||||
RuntimeTargetWire, execute_runtime_input,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
pub fn runtime_context(
|
||||
context: &RequestContext,
|
||||
@@ -63,22 +64,22 @@ pub fn build_runtime_command_plan(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_convex(
|
||||
pub async fn execute_runtime_command_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_command_plan(context, effective_workspace_id, command)?;
|
||||
execute_convex_command_plan(config, context, &plan).await
|
||||
execute_retired_command_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_convex_with_artifacts(
|
||||
pub async fn execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<ConvexCommandExecution, WebError> {
|
||||
) -> Result<RetiredCloudCommandExecution, WebError> {
|
||||
let runtime_context = runtime_context(context, effective_workspace_id);
|
||||
let runtime_input = RuntimeInput::Command {
|
||||
context: runtime_context.clone(),
|
||||
@@ -90,7 +91,7 @@ pub async fn execute_runtime_command_via_convex_with_artifacts(
|
||||
return Err(WebError::internal("runtime command 未返回 command plan").with_context(context));
|
||||
};
|
||||
|
||||
let execution = execute_convex_command_plan_with_artifacts(
|
||||
let execution = execute_retired_command_plan_with_artifacts(
|
||||
state.config(),
|
||||
context,
|
||||
&runtime_context,
|
||||
|
||||
@@ -56,7 +56,7 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -3,24 +3,25 @@ use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self, BufferKey, BufferStore};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
execute_runtime_command_via_legacy_cloud,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
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 serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -48,6 +49,17 @@ pub struct BufferStateQuery {
|
||||
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,
|
||||
@@ -104,6 +116,20 @@ fn find_document_buffer_state(
|
||||
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 {
|
||||
@@ -114,6 +140,8 @@ pub struct DocumentSaveRequest {
|
||||
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>,
|
||||
@@ -179,7 +207,9 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
fn execution_artifacts_json(
|
||||
execution: &crate::transport::convex::RetiredCloudCommandExecution,
|
||||
) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
@@ -480,6 +510,8 @@ async fn proxy_next_documents_save(
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"writeIntentId": body.write_intent_id,
|
||||
"saveOperationId": body.save_operation_id,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -522,7 +554,7 @@ pub async fn load_document_meta_result(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
fetch_documents_meta_via_convex(
|
||||
fetch_documents_meta_via_legacy_cloud(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -553,7 +585,7 @@ pub async fn load_document_content_result(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
execute_runtime_query_via_convex(
|
||||
execute_runtime_query_via_legacy_cloud(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -599,20 +631,7 @@ pub async fn buffer_state(
|
||||
let result = find_document_buffer_state(&state.buffer_store, &query);
|
||||
|
||||
match result {
|
||||
Some(buf) => Ok(ok_response(
|
||||
&context,
|
||||
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,
|
||||
}),
|
||||
)),
|
||||
Some(buf) => Ok(ok_response(&context, buffer_state_payload(buf))),
|
||||
None => Err(WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"buffer_state_not_found",
|
||||
@@ -622,6 +641,51 @@ pub async fn buffer_state(
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记指定打开文档的 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(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -690,6 +754,8 @@ pub async fn save(
|
||||
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(),
|
||||
base_content_hash: body.base_content_hash.clone(),
|
||||
content_format: body
|
||||
.content_format
|
||||
@@ -740,6 +806,8 @@ pub async fn save(
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"writeIntentId": body.write_intent_id,
|
||||
"saveOperationId": body.save_operation_id,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -752,7 +820,7 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -815,7 +883,7 @@ pub async fn purge(
|
||||
validate_only: false,
|
||||
};
|
||||
let result =
|
||||
execute_runtime_command_via_convex(state.config(), &context, None, command).await?;
|
||||
execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -865,7 +933,7 @@ pub async fn empty_trash(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
Some(workspace_id),
|
||||
@@ -968,13 +1036,14 @@ pub async fn title(
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page title update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.head.updateTitle".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(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1072,13 +1141,14 @@ pub async fn options(
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page layout update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.layout.updateOptions".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(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1111,9 +1181,9 @@ pub async fn options(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
@@ -1445,8 +1515,7 @@ mod tests {
|
||||
"upsert_document"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
|
||||
["title"],
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"],
|
||||
"服务端页面(改名)"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
@@ -1727,15 +1796,19 @@ mod tests {
|
||||
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
|
||||
Some(stale_file_version.as_str())
|
||||
);
|
||||
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")));
|
||||
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"));
|
||||
|
||||
@@ -2,14 +2,14 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::documents::{
|
||||
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
|
||||
DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
|
||||
};
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1763,10 +1763,10 @@ pub async fn transform_runtime_snapshot(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
load_local_trash_entries,
|
||||
};
|
||||
@@ -17,24 +18,24 @@ use crate::routes::web_shell::{
|
||||
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
|
||||
render_local_sidebar_tree_html_from_snapshot,
|
||||
};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::transport::convex::execute_retired_mutation_by_name;
|
||||
use crate::workspace_shell::{
|
||||
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
||||
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
|
||||
use axum::http::{HeaderName, HeaderValue, Request, StatusCode, Uri, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use control_plane::{
|
||||
session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||
UpsertUserInput,
|
||||
AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||
NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, session_token_hash,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use leptos::prelude::InnerHtmlAttribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
@@ -73,6 +74,8 @@ pub(crate) struct RootEntryQuery {
|
||||
file_tree_scope: Option<String>,
|
||||
tree_view: Option<String>,
|
||||
restore_focus_row_id: Option<String>,
|
||||
route_guard: Option<String>,
|
||||
missing_page: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn gateway_health(State(state): State<AppState>) -> Response {
|
||||
@@ -260,12 +263,23 @@ pub async fn user_access_policy_entry(
|
||||
pub async fn root_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
uri: Uri,
|
||||
Query(query): Query<RootEntryQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
if !has_real_auth_context(&state, &context) {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/auth")
|
||||
.header(
|
||||
header::LOCATION,
|
||||
format!(
|
||||
"/auth?next={}",
|
||||
query_escape(
|
||||
uri.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or("/")
|
||||
)
|
||||
),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
@@ -350,21 +364,16 @@ pub async fn root_entry(
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
let mut workspace_projection = build_workspace_shell_projection(
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_or_recent_page_id.as_deref(),
|
||||
"本地文件夹",
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
None,
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
|
||||
if selected_active_page_id.is_none() {
|
||||
apply_active_page(&mut workspace_projection, None);
|
||||
}
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
|
||||
&page_tree_snapshot,
|
||||
selected_active_page_id.as_deref(),
|
||||
@@ -419,21 +428,16 @@ pub async fn root_entry(
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
let mut workspace_projection = build_workspace_shell_projection(
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_page_id.as_deref(),
|
||||
&default_workspace_name,
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
|
||||
if selected_active_page_id.is_none() {
|
||||
apply_active_page(&mut workspace_projection, None);
|
||||
}
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
|
||||
&snapshot,
|
||||
selected_active_page_id.as_deref(),
|
||||
@@ -521,6 +525,30 @@ pub async fn root_entry(
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
|
||||
let navigation_notice_html = render_navigation_guard_notice(&query);
|
||||
let navigation_html = if active_page_id.trim().is_empty() {
|
||||
if active_source_kind.as_deref() == Some("local_folder") {
|
||||
active_root_uri
|
||||
.as_deref()
|
||||
.map(|root_uri| {
|
||||
render_local_folder_navigation_page_html(
|
||||
&state,
|
||||
&context,
|
||||
&actor_id,
|
||||
root_uri,
|
||||
file_tree_scope,
|
||||
&navigation_notice_html,
|
||||
)
|
||||
})
|
||||
.transpose()?
|
||||
} else {
|
||||
Some(render_workspace_navigation_page_html(
|
||||
&navigation_notice_html,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let render_workspace_entry = || {
|
||||
crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::home::HomePage
|
||||
@@ -530,28 +558,25 @@ pub async fn root_entry(
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
active_page_id={active_page_id.clone()}
|
||||
active_page_title={active_page_title.clone()}
|
||||
navigation_html={navigation_html.clone().unwrap_or_default()}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
/>
|
||||
})
|
||||
};
|
||||
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
|
||||
let body_extra = if active_source_kind.as_deref() == Some("local_folder") {
|
||||
let panes_bootstrap_json = serde_json::to_string(&json!({
|
||||
"schema": "mnote.document_panes_bootstrap.v1",
|
||||
"secondaryRequested": false,
|
||||
"secondaryInvalid": false,
|
||||
"panes": [],
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
format!(
|
||||
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
||||
let panes_bootstrap_json = serde_json::to_string(&json!({
|
||||
"schema": "mnote.document_panes_bootstrap.v1",
|
||||
"secondaryRequested": false,
|
||||
"secondaryInvalid": false,
|
||||
"panes": [],
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let body_extra = format!(
|
||||
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}"#,
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
render_editor_island_adapter_script(),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
render_editor_island_adapter_script(),
|
||||
);
|
||||
("MNOTE".to_string(), render_workspace_entry(), body_extra)
|
||||
} else {
|
||||
match build_page_aggregate_snapshot(
|
||||
@@ -617,8 +642,7 @@ pub async fn root_entry(
|
||||
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
|
||||
}
|
||||
};
|
||||
let editor_runtime_preload_links = if body_extra.contains("document-editor-adapter-runtime.js")
|
||||
{
|
||||
let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") {
|
||||
render_editor_runtime_preload_links()
|
||||
} else {
|
||||
""
|
||||
@@ -651,6 +675,333 @@ pub async fn root_entry(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn render_workspace_navigation_page_html(notice_html: &str) -> String {
|
||||
format!(
|
||||
r#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="workspace" data-navigation-relative-path="">
|
||||
<header class="mnote-navigation-page__header">
|
||||
<h1>主页</h1>
|
||||
</header>
|
||||
{notice_html}
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
|
||||
<h2>最近访问的文件夹</h2>
|
||||
<p>暂无最近访问的文件夹。</p>
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
|
||||
<h2>最近访问的页面</h2>
|
||||
<p>暂无最近访问的页面。</p>
|
||||
</section>
|
||||
</section>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn render_local_folder_navigation_page_html(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
actor_id: &str,
|
||||
root_uri: &str,
|
||||
relative_path: Option<&str>,
|
||||
notice_html: &str,
|
||||
) -> Result<String, WebError> {
|
||||
let relative_path = relative_path.map(str::trim).unwrap_or_default();
|
||||
let snapshot = if relative_path.is_empty() {
|
||||
load_local_folder_file_tree_snapshot(root_uri)?
|
||||
} else {
|
||||
load_local_folder_file_tree_children_snapshot(root_uri, relative_path)?
|
||||
};
|
||||
let items = snapshot
|
||||
.projection
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let title = if relative_path.is_empty() {
|
||||
"本地文件夹".to_string()
|
||||
} else {
|
||||
relative_path
|
||||
.split('/')
|
||||
.next_back()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(relative_path)
|
||||
.to_string()
|
||||
};
|
||||
let mut folders = Vec::new();
|
||||
let mut pages = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
for item in items {
|
||||
let row_kind = item
|
||||
.get("rowKind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let title = item
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("无标题");
|
||||
let resource_meta = item.get("resourceMeta").and_then(Value::as_object);
|
||||
let item_relative_path = item
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("workspacePath"))
|
||||
.and_then(|workspace_path| workspace_path.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let document_id = resource_meta
|
||||
.and_then(|meta| meta.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
match row_kind {
|
||||
"folder" | "directory" | "asset-folder" => folders.push(render_navigation_link(
|
||||
title,
|
||||
&navigation_folder_href(root_uri, item_relative_path),
|
||||
"folder",
|
||||
item_relative_path,
|
||||
)),
|
||||
"document" | "markdown" | "index" => {
|
||||
if let Some(document_id) = document_id {
|
||||
pages.push(render_navigation_link(
|
||||
title,
|
||||
&navigation_document_href(root_uri, relative_path, document_id),
|
||||
"page",
|
||||
item_relative_path,
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => resources.push(render_navigation_link(
|
||||
title,
|
||||
"#",
|
||||
"resource",
|
||||
item_relative_path,
|
||||
)),
|
||||
}
|
||||
}
|
||||
record_navigation_recent_folder(state, actor_id, root_uri, relative_path, &title, None);
|
||||
let recent_folders = crate::routes::navigation_recent::load_navigation_recent_records(
|
||||
state,
|
||||
context,
|
||||
actor_id,
|
||||
Some("folder"),
|
||||
10,
|
||||
);
|
||||
let recent_pages = crate::routes::navigation_recent::load_navigation_recent_records(
|
||||
state,
|
||||
context,
|
||||
actor_id,
|
||||
Some("page"),
|
||||
20,
|
||||
);
|
||||
|
||||
Ok(format!(
|
||||
r#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="folder" data-navigation-relative-path="{relative_path}">
|
||||
<header class="mnote-navigation-page__header">
|
||||
<h1>{title}</h1>
|
||||
<p>{path_label}</p>
|
||||
</header>
|
||||
{notice_html}
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-folders">
|
||||
<h2>文件夹</h2>
|
||||
{folders}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-pages">
|
||||
<h2>页面</h2>
|
||||
{pages}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-resources">
|
||||
<h2>资源</h2>
|
||||
{resources}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
|
||||
<h2>最近访问的文件夹</h2>
|
||||
{recent_folders}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
|
||||
<h2>最近访问的页面</h2>
|
||||
{recent_pages}
|
||||
</section>
|
||||
</section>"#,
|
||||
relative_path = escape_html(relative_path),
|
||||
title = escape_html(&title),
|
||||
path_label = escape_html(if relative_path.is_empty() {
|
||||
root_uri
|
||||
} else {
|
||||
relative_path
|
||||
}),
|
||||
notice_html = notice_html,
|
||||
folders = if folders.is_empty() {
|
||||
"<p>没有子文件夹。</p>".to_string()
|
||||
} else {
|
||||
folders.join("")
|
||||
},
|
||||
pages = if pages.is_empty() {
|
||||
"<p>没有 Markdown 页面。</p>".to_string()
|
||||
} else {
|
||||
pages.join("")
|
||||
},
|
||||
resources = if resources.is_empty() {
|
||||
"<p>没有资源。</p>".to_string()
|
||||
} else {
|
||||
resources.join("")
|
||||
},
|
||||
recent_folders = render_recent_navigation_items(&recent_folders),
|
||||
recent_pages = render_recent_navigation_items(&recent_pages),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_navigation_guard_notice(query: &RootEntryQuery) -> String {
|
||||
let route_guard = query
|
||||
.route_guard
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let missing_page = query
|
||||
.missing_page
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let Some(message) = route_guard.or(missing_page).map(|_| {
|
||||
if route_guard == Some("local_workspace_access_denied") {
|
||||
"当前账号无权访问原页面,已回到导航页。"
|
||||
} else if route_guard == Some("local_folder_root_required") {
|
||||
"原页面缺少本地文件夹信息,已回到导航页。"
|
||||
} else {
|
||||
"原页面已不存在,已回到当前文件夹导航页。"
|
||||
}
|
||||
}) else {
|
||||
return String::new();
|
||||
};
|
||||
let target = missing_page.or(route_guard).unwrap_or_default();
|
||||
format!(
|
||||
r#"<div class="mnote-navigation-page__notice" data-testid="mnote-navigation-guard-notice" data-mnote-route-guard-target="{}">{}<span>{}</span></div>"#,
|
||||
escape_html(target),
|
||||
escape_html(message),
|
||||
escape_html(target),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_recent_navigation_items(records: &[NavigationRecentRecord]) -> String {
|
||||
if records.is_empty() {
|
||||
return "<p>暂无最近访问。</p>".to_string();
|
||||
}
|
||||
records
|
||||
.iter()
|
||||
.map(|record| {
|
||||
let href = if record.kind == "folder" {
|
||||
navigation_folder_href(
|
||||
&record.root_uri,
|
||||
record.relative_path.as_deref().unwrap_or(""),
|
||||
)
|
||||
} else if let Some(document_id) = record.document_id.as_deref() {
|
||||
navigation_document_href(
|
||||
&record.root_uri,
|
||||
parent_scope_from_relative_path(record.relative_path.as_deref().unwrap_or("")),
|
||||
document_id,
|
||||
)
|
||||
} else {
|
||||
"#".to_string()
|
||||
};
|
||||
render_navigation_link(
|
||||
&record.title,
|
||||
&href,
|
||||
&record.kind,
|
||||
record.relative_path.as_deref().unwrap_or(""),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn render_navigation_link(title: &str, href: &str, kind: &str, relative_path: &str) -> String {
|
||||
format!(
|
||||
r#"<a class="mnote-navigation-page__item" data-navigation-item-kind="{}" data-local-relative-path="{}" href="{}">{}</a>"#,
|
||||
escape_html(kind),
|
||||
escape_html(relative_path),
|
||||
escape_html(href),
|
||||
escape_html(title),
|
||||
)
|
||||
}
|
||||
|
||||
fn record_navigation_recent_folder(
|
||||
state: &AppState,
|
||||
actor_id: &str,
|
||||
root_uri: &str,
|
||||
relative_path: &str,
|
||||
title: &str,
|
||||
workspace_id: Option<&str>,
|
||||
) {
|
||||
if actor_id.trim().is_empty() || actor_id.trim() == "anonymous" || root_uri.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: actor_id.trim().to_string(),
|
||||
workspace_id: workspace_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.trim().to_string(),
|
||||
relative_path: Some(relative_path.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
document_id: None,
|
||||
title: title.trim().to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn parent_scope_from_relative_path(relative_path: &str) -> &str {
|
||||
relative_path
|
||||
.trim()
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent)
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
|
||||
let mut href = format!(
|
||||
"/?sourceKind=local_folder&rootUri={}&treeView=filetree",
|
||||
query_escape(root_uri)
|
||||
);
|
||||
if !relative_path.trim().is_empty() {
|
||||
href.push_str("&fileTreeScope=");
|
||||
href.push_str(&query_escape(relative_path));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id: &str) -> String {
|
||||
let mut href = format!(
|
||||
"/documents/{}?sourceKind=local_folder&rootUri={}&treeView=filetree",
|
||||
query_escape(document_id),
|
||||
query_escape(root_uri)
|
||||
);
|
||||
if !file_tree_scope.trim().is_empty() {
|
||||
href.push_str("&fileTreeScope=");
|
||||
href.push_str(&query_escape(file_tree_scope));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn query_escape(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.flat_map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
|
||||
vec![byte as char]
|
||||
}
|
||||
_ => format!("%{byte:02X}").chars().collect::<Vec<_>>(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn trash_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1606,7 +1957,7 @@ async fn resolve_root_workspace_id(
|
||||
return Ok(workspace_id.to_string());
|
||||
}
|
||||
|
||||
let bootstrap = execute_convex_mutation_by_name(
|
||||
let bootstrap = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"workspaces:ensureDefaultWorkspace",
|
||||
@@ -2097,11 +2448,12 @@ fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2334,7 +2686,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(
|
||||
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
|
||||
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.table","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
|
||||
),
|
||||
)
|
||||
.oneshot(
|
||||
@@ -2367,7 +2719,7 @@ mod tests {
|
||||
assert!(html.contains(r#"data-resource-kind="table""#));
|
||||
assert!(html.contains("已删附件.png"));
|
||||
assert!(html.contains("已删思维导图.json"));
|
||||
assert!(html.contains("已删表格.luckysheet"));
|
||||
assert!(html.contains("已删表格.table"));
|
||||
assert!(html.contains("new EventSource"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(!html.contains("pollMs"));
|
||||
@@ -2476,6 +2828,30 @@ mod tests {
|
||||
assert_eq!(empty, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_unauthenticated_redirects_to_auth_with_next() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/?sourceKind=local_folder&rootUri=file:///tmp/mnote&treeView=filetree")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(
|
||||
"/auth?next=%2F%3FsourceKind%3Dlocal_folder%26rootUri%3Dfile%3A%2F%2F%2Ftmp%2Fmnote%26treeView%3Dfiletree"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_uses_recent_page_cookie_as_active_page() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
@@ -2580,8 +2956,10 @@ mod tests {
|
||||
assert!(!html.contains("初始化的新页面"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
@@ -2641,7 +3019,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
|
||||
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
|
||||
|
||||
let app_state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -2827,6 +3205,205 @@ mod tests {
|
||||
assert!(!html.contains(r#"href="/tree"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_without_page_renders_navigation_page_without_editor_bootstrap()
|
||||
{
|
||||
let root = temp_root("mnote-root-local-folder-navigation-page");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
|
||||
std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("cookie", "mnote_recent_page_id=local-md:README.md")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-navigation-scope-kind="folder""#));
|
||||
assert!(html.contains("README.md"));
|
||||
assert!(html.contains("docs"));
|
||||
assert!(html.contains("最近访问的文件夹"));
|
||||
assert!(html.contains("最近访问的页面"));
|
||||
assert!(!html.contains(r#"data-root-active-page-id="local-md:README.md""#));
|
||||
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
assert!(!html.contains("__MNOTE_EDITOR_BOOTSTRAP__"));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(!html.contains("mnote-leptos-tiptap-spike-island"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_scope_renders_navigation_without_root_sibling_document() {
|
||||
let root = temp_root("mnote-root-local-folder-scope-navigation-page");
|
||||
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
|
||||
.expect("create scoped tree");
|
||||
std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page");
|
||||
std::fs::write(root.join("design").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design&missingPage=local-md%3Adesign%7E2FMissing.md"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-navigation-relative-path="design""#));
|
||||
assert!(html.contains("Plan.md"));
|
||||
assert!(html.contains("05-editor-mainline"));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-guard-notice""#));
|
||||
assert!(html.contains("原页面已不存在"));
|
||||
assert!(html.contains("local-md:design~2FMissing.md"));
|
||||
assert!(!html.contains("Home.md"));
|
||||
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_navigation_renders_user_recent_links() {
|
||||
let root = temp_root("mnote-root-local-folder-navigation-recent");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
std::fs::create_dir_all(root.join("archive")).expect("create archive");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("archive".to_string()),
|
||||
document_id: None,
|
||||
title: "archive".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("folder recent");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "page".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("docs/Plan.md".to_string()),
|
||||
document_id: Some("local-md:docs~2FPlan.md".to_string()),
|
||||
title: "Plan".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("page recent");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-recent-folders""#));
|
||||
assert!(html.contains(r#"data-local-relative-path="archive""#));
|
||||
assert!(html.contains("fileTreeScope=archive"));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-recent-pages""#));
|
||||
assert!(html.contains(r#"href="/documents/local-md%3Adocs%7E2FPlan.md"#));
|
||||
assert!(html.contains("fileTreeScope=docs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html() {
|
||||
let root = temp_root("mnote-root-local-folder-page-tree-snapshot-once");
|
||||
@@ -2859,7 +3436,9 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
crate::routes::local_folder_source::local_page_tree_snapshot_test_loads(),
|
||||
crate::routes::local_folder_source::local_page_tree_snapshot_test_loads_for_root(
|
||||
&root_uri
|
||||
),
|
||||
1,
|
||||
"root entry should reuse the already loaded local page tree snapshot instead of scanning it twice",
|
||||
);
|
||||
@@ -2907,7 +3486,9 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
|
||||
assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
|
||||
assert!(
|
||||
html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)
|
||||
);
|
||||
assert!(
|
||||
!html.contains(r#"data-node-id="local-md:Home.md""#),
|
||||
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
|
||||
@@ -2953,17 +3534,18 @@ mod tests {
|
||||
assert!(html.contains(r#"data-row-id="local:folder:attachments""#));
|
||||
assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#));
|
||||
assert!(html.contains(r#"data-pane-role="primary""#));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(html.contains(
|
||||
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
assert!(!html.contains(
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
assert!(!html.contains(
|
||||
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
));
|
||||
assert!(
|
||||
@@ -2991,7 +3573,7 @@ mod tests {
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("/auth")
|
||||
Some("/auth?next=%2F")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
@@ -3090,12 +3672,14 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert!(response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.contains("text/html"));
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.contains("text/html")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3150,12 +3734,16 @@ mod tests {
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(values.iter().any(|value| value.contains("mnote_session=")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=new-user")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_type=user")));
|
||||
assert!(
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=new-user"))
|
||||
);
|
||||
assert!(
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_type=user"))
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
@@ -3234,10 +3822,12 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "auth_signup_email_required");
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("注册账号时请填写邮箱"));
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("注册账号时请填写邮箱")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3261,9 +3851,11 @@ mod tests {
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
|
||||
assert!(
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3369,12 +3961,16 @@ mod tests {
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthJWT=")));
|
||||
assert!(!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthRefreshToken=")));
|
||||
assert!(
|
||||
!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthJWT="))
|
||||
);
|
||||
assert!(
|
||||
!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthRefreshToken="))
|
||||
);
|
||||
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||
runtime_input_requests_result, RuntimeInput,
|
||||
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
|
||||
execute_runtime_query, runtime_input_requests_result,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
|
||||
@@ -150,10 +150,10 @@ fn stamp_ai_bridge_headers() -> HeaderMap {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -253,10 +253,12 @@ mod tests {
|
||||
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert!(payload["eventStreamEndpoint"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("/api/hermes/events/"));
|
||||
assert!(
|
||||
payload["eventStreamEndpoint"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("/api/hermes/events/")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,11 @@ use super::hermes_client;
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
|
||||
use crate::hermes_tools::{ToolCallInput, artifact, block, doc, manifest, page, resource};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
@@ -827,10 +827,10 @@ fn stamp_tool_headers() -> HeaderMap {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
@@ -1112,36 +1112,56 @@ mod tests {
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.replace"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.insert_after"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.delete"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.move_after"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.plan_update")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.replace")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.insert_after")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.delete")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.move_after")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.fetch")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.fetch_summary")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.propose_changes")
|
||||
);
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
@@ -1189,16 +1209,20 @@ mod tests {
|
||||
}
|
||||
assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object());
|
||||
assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
|
||||
assert!(markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["operations"])));
|
||||
assert!(markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["full_content"])));
|
||||
assert!(
|
||||
markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["operations"]))
|
||||
);
|
||||
assert!(
|
||||
markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["full_content"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1228,14 +1252,18 @@ mod tests {
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
.expect("page save tool");
|
||||
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("兼容"));
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("agent 原生 patch/diff"));
|
||||
assert!(
|
||||
markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("兼容")
|
||||
);
|
||||
assert!(
|
||||
markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("agent 原生 patch/diff")
|
||||
);
|
||||
assert_eq!(
|
||||
page_save["annotations"]["requiresWritePermission"],
|
||||
Value::Bool(true)
|
||||
@@ -1426,10 +1454,12 @@ mod tests {
|
||||
payload["result"]["objectIdentity"],
|
||||
"resource:mindmap:local-md:README.md:mind_allowed"
|
||||
);
|
||||
assert!(payload["result"]["markdownSummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("中心主题"));
|
||||
assert!(
|
||||
payload["result"]["markdownSummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("中心主题")
|
||||
);
|
||||
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1765,10 +1795,12 @@ mod tests {
|
||||
assert_eq!(payload["toolName"], "mnote.page.get");
|
||||
assert_eq!(payload["toolCallId"], "call_1");
|
||||
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||
assert!(payload["result"]["bodySummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("章节一"));
|
||||
assert!(
|
||||
payload["result"]["bodySummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("章节一")
|
||||
);
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
}
|
||||
|
||||
@@ -1814,10 +1846,12 @@ mod tests {
|
||||
json!("heading_1")
|
||||
);
|
||||
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
|
||||
assert!(payload["result"]["blocks"][0]["revisionRef"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("pageRev:7:block:heading_1:hash:"));
|
||||
assert!(
|
||||
payload["result"]["blocks"][0]["revisionRef"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("pageRev:7:block:heading_1:hash:")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1944,10 +1978,12 @@ mod tests {
|
||||
assert_eq!(payload["result"]["scope"], "selection");
|
||||
assert_eq!(payload["result"]["format"], "page_xml");
|
||||
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
|
||||
assert!(payload["result"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("<block id=\"heading_1\""));
|
||||
assert!(
|
||||
payload["result"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("<block id=\"heading_1\"")
|
||||
);
|
||||
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
|
||||
}
|
||||
|
||||
@@ -2210,10 +2246,12 @@ mod tests {
|
||||
insert_payload["result"]["changedBlocks"][0]["op"],
|
||||
json!("insert_after")
|
||||
);
|
||||
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("ai_block_"));
|
||||
assert!(
|
||||
insert_payload["result"]["changedBlocks"][0]["blockId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("ai_block_")
|
||||
);
|
||||
|
||||
let delete_response = app()
|
||||
.oneshot(
|
||||
@@ -2565,9 +2603,11 @@ mod tests {
|
||||
.as_array()
|
||||
.expect("insertedBlockIds");
|
||||
assert_eq!(inserted_ids.len(), 2);
|
||||
assert!(inserted_ids
|
||||
.iter()
|
||||
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
|
||||
assert!(
|
||||
inserted_ids
|
||||
.iter()
|
||||
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") })
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["changedBlocks"]
|
||||
.as_array()
|
||||
|
||||
@@ -8,16 +8,16 @@ use crate::routes::local_folder_source::{
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
||||
subtree_query,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(test)]
|
||||
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
||||
@@ -264,9 +264,9 @@ pub async fn graph(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -287,7 +287,7 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -409,17 +409,19 @@ mod tests {
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["result"]["projection"], "file_tree");
|
||||
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
||||
assert!(payload["result"]["items"]
|
||||
.as_array()
|
||||
.expect("items")
|
||||
.iter()
|
||||
.any(|item| item["title"] == "本地页面"
|
||||
&& item["rowKind"] == "folder"
|
||||
&& item["documentId"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:")
|
||||
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面"));
|
||||
assert!(
|
||||
payload["result"]["items"]
|
||||
.as_array()
|
||||
.expect("items")
|
||||
.iter()
|
||||
.any(|item| item["title"] == "本地页面"
|
||||
&& item["rowKind"] == "folder"
|
||||
&& item["documentId"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:")
|
||||
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -513,9 +515,11 @@ mod tests {
|
||||
assert!(titles.contains(&"README.md"));
|
||||
assert!(titles.contains(&"nested"));
|
||||
assert!(!titles.contains(&"deep.md"));
|
||||
assert!(items
|
||||
.iter()
|
||||
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs")));
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs"))
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -676,11 +680,13 @@ mod tests {
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
|
||||
"mindmap"
|
||||
);
|
||||
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value == "expand"));
|
||||
assert!(
|
||||
item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value == "expand")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,16 +10,16 @@ use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, Sse};
|
||||
use futures_util::stream;
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::time::{interval, timeout, MissedTickBehavior};
|
||||
use tokio::time::{MissedTickBehavior, interval, timeout};
|
||||
|
||||
type BoxedEventStream =
|
||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
@@ -29,6 +29,7 @@ type BoxedEventStream =
|
||||
pub struct LocalFolderEventsQuery {
|
||||
pub root_uri: String,
|
||||
pub document_id: Option<String>,
|
||||
pub resource_path: Option<String>,
|
||||
pub tree_live: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -68,7 +69,8 @@ async fn build_document_events_stream(
|
||||
let document_relative_path = query
|
||||
.document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id);
|
||||
.and_then(local_markdown_relative_path_from_document_id)
|
||||
.or_else(|| query.resource_path.as_deref().and_then(normalize_resource_event_path));
|
||||
let subscription = state
|
||||
.local_folder_watcher_registry()
|
||||
.subscribe(&canonical_root)
|
||||
@@ -78,6 +80,7 @@ async fn build_document_events_stream(
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": subscription.root_uri(),
|
||||
"documentId": query.document_id,
|
||||
"resourcePath": query.resource_path,
|
||||
"revision": system_time_ms(SystemTime::now()),
|
||||
});
|
||||
let stream = stream::unfold(
|
||||
@@ -117,7 +120,7 @@ async fn build_document_events_stream(
|
||||
/// with full sidebar + file tree projections.
|
||||
///
|
||||
/// Reuses `LocalFolderWatcherRegistry` — no second watcher created.
|
||||
/// No data is written to Convex command log.
|
||||
/// No data is written to an external/cloud command log.
|
||||
async fn build_tree_live_stream(
|
||||
state: AppState,
|
||||
context: RequestContext,
|
||||
@@ -423,6 +426,18 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<St
|
||||
decode_local_id_segment(encoded).ok()
|
||||
}
|
||||
|
||||
fn normalize_resource_event_path(resource_path: &str) -> Option<String> {
|
||||
let trimmed = resource_path.trim().trim_start_matches('/');
|
||||
if trimmed.is_empty()
|
||||
|| PathBuf::from(trimmed)
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.replace('\\', "/"))
|
||||
}
|
||||
|
||||
fn document_event_targets_relative_path(payload: &Value, expected: &str) -> bool {
|
||||
let relative_path = payload
|
||||
.get("relativePath")
|
||||
@@ -457,7 +472,7 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
@@ -577,6 +592,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_event_path_filters_to_its_own_relative_path() {
|
||||
let expected = normalize_resource_event_path("README.assets/slides.pptx")
|
||||
.expect("resource path should normalize");
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"relativePath": "README.assets/slides.pptx",
|
||||
"documentId": "",
|
||||
"eventKind": "Modify(Data)",
|
||||
"revision": 1,
|
||||
});
|
||||
let other_payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"relativePath": "README.assets/report.pdf",
|
||||
"documentId": "",
|
||||
"eventKind": "Modify(Data)",
|
||||
"revision": 2,
|
||||
});
|
||||
|
||||
assert!(document_event_targets_relative_path(&payload, &expected));
|
||||
assert!(!document_event_targets_relative_path(&other_payload, &expected));
|
||||
assert!(
|
||||
normalize_resource_event_path("../escape.pdf").is_none(),
|
||||
"resource watch path 不能越过 root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tree_snapshot_payload_has_required_fields() {
|
||||
let sidebar_projection = json!({
|
||||
@@ -697,11 +741,13 @@ mod tests {
|
||||
&& parent["reason"].as_str() == Some("child-watch")),
|
||||
"watch batch 应声明 docs affected parent: {payload}"
|
||||
);
|
||||
assert!(payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)")));
|
||||
assert!(
|
||||
payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)"))
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ use crate::routes::local_markdown_parser::{
|
||||
};
|
||||
use crate::routes::local_search_index;
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use bridge_runtime::project_legacy_content_to_block_document;
|
||||
use control_plane::AppendAuditInput;
|
||||
use control_plane::{
|
||||
@@ -24,7 +24,7 @@ use core_protocol::{
|
||||
};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -41,6 +41,9 @@ static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS: std::sync::atomic::AtomicU64 =
|
||||
#[cfg(test)]
|
||||
static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
#[cfg(test)]
|
||||
static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT: OnceLock<Mutex<BTreeMap<String, u64>>> =
|
||||
OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LocalPageTreeSnapshotCacheEntry {
|
||||
@@ -52,8 +55,8 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
|
||||
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn local_page_tree_snapshot_cache(
|
||||
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
|
||||
fn local_page_tree_snapshot_cache()
|
||||
-> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
}
|
||||
|
||||
@@ -61,6 +64,11 @@ fn local_page_tree_snapshot_cache(
|
||||
pub(crate) fn reset_local_page_tree_snapshot_test_loads() {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(loads) = LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT.get() {
|
||||
if let Ok(mut loads) = loads.lock() {
|
||||
loads.clear();
|
||||
}
|
||||
}
|
||||
if let Some(cache) = LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get() {
|
||||
if let Ok(mut cache) = cache.lock() {
|
||||
cache.clear();
|
||||
@@ -69,8 +77,13 @@ pub(crate) fn reset_local_page_tree_snapshot_test_loads() {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn local_page_tree_snapshot_test_loads() -> u64 {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst)
|
||||
pub(crate) fn local_page_tree_snapshot_test_loads_for_root(root_uri: &str) -> u64 {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT
|
||||
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|loads| loads.get(root_uri).copied())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2784,6 +2797,15 @@ fn load_local_folder_page_tree_snapshot_for_scope(
|
||||
) -> Result<ProjectionSnapshot, WebError> {
|
||||
#[cfg(test)]
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Ok(mut loads) = LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT
|
||||
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
.lock()
|
||||
{
|
||||
*loads.entry(root_uri.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
@@ -3255,10 +3277,12 @@ pub fn write_local_markdown_page_body(
|
||||
relative_path,
|
||||
&request.document_id,
|
||||
);
|
||||
store.mark_saved(
|
||||
store.mark_saved_with_operation(
|
||||
&ws_path,
|
||||
file_version.to_string(),
|
||||
format!("sha256:{file_version}"),
|
||||
request.write_intent_id.clone(),
|
||||
request.save_operation_id.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3283,6 +3307,22 @@ pub fn write_local_markdown_page_body(
|
||||
.map(Value::from)
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
map.insert(
|
||||
"writeIntentId".into(),
|
||||
request
|
||||
.write_intent_id
|
||||
.as_deref()
|
||||
.map(Value::from)
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
map.insert(
|
||||
"saveOperationId".into(),
|
||||
request
|
||||
.save_operation_id
|
||||
.as_deref()
|
||||
.map(Value::from)
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -3818,7 +3858,7 @@ pub(crate) fn write_local_folder_file_upload(
|
||||
"上传目标必须是本地 root 内的目录",
|
||||
));
|
||||
}
|
||||
let sanitized_name = sanitize_file_name(&file.name, "附件");
|
||||
let sanitized_name = sanitize_uploaded_file_name(&file.name, "附件");
|
||||
let target = next_available_raw_path(&target_dir, &sanitized_name);
|
||||
fs::write(&target, &file.bytes).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -3908,7 +3948,7 @@ pub(crate) fn write_local_markdown_asset(
|
||||
)
|
||||
})?;
|
||||
|
||||
let sanitized_name = sanitize_file_name(&file.name, "附件");
|
||||
let sanitized_name = sanitize_uploaded_file_name(&file.name, "附件");
|
||||
let target = next_available_asset_path(&asset_dir, &sanitized_name);
|
||||
if !target.starts_with(&canonical_root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -7023,11 +7063,7 @@ fn parent_key_for_relative_path(relative_path: &str) -> String {
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
.map(|value| normalize_file_order_parent_key(&value))
|
||||
.unwrap_or_else(|| ".".to_string())
|
||||
@@ -7517,11 +7553,16 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
||||
}
|
||||
"asset" => match row.icon_hint.as_str() {
|
||||
"mindmap" => KernelObjectKind::Mindmap,
|
||||
"onlyoffice" | "office" => KernelObjectKind::OnlyOffice,
|
||||
"onlyoffice" | "office" | "word" | "ppt" | "sheet" => KernelObjectKind::OnlyOffice,
|
||||
_ => KernelObjectKind::Attachment,
|
||||
},
|
||||
_ => KernelObjectKind::Attachment,
|
||||
};
|
||||
let object_asset_id = if row.row_kind == "asset" && !row.relative_path.trim().is_empty() {
|
||||
Some(format!("local-file:{}", row.relative_path))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let workspace_path = ObjectWorkspacePath {
|
||||
workspace_id: row.workspace_id.clone(),
|
||||
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||
@@ -7531,7 +7572,7 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
||||
object_kind,
|
||||
document_id: row.document_id.clone(),
|
||||
block_id: None,
|
||||
asset_id: None,
|
||||
asset_id: object_asset_id,
|
||||
},
|
||||
resource_kind: Some(row.row_kind.clone()),
|
||||
};
|
||||
@@ -7733,21 +7774,22 @@ fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String {
|
||||
"pdf"
|
||||
} else if matches!(
|
||||
extension(&lower).as_deref(),
|
||||
Some(
|
||||
"doc"
|
||||
| "docx"
|
||||
| "odt"
|
||||
| "rtf"
|
||||
| "ppt"
|
||||
| "pptx"
|
||||
| "odp"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "ods"
|
||||
| "csv"
|
||||
)
|
||||
Some("doc" | "docx" | "odt" | "rtf")
|
||||
) {
|
||||
"office"
|
||||
"word"
|
||||
} else if matches!(extension(&lower).as_deref(), Some("ppt" | "pptx" | "odp")) {
|
||||
"ppt"
|
||||
} else if matches!(
|
||||
extension(&lower).as_deref(),
|
||||
Some("xls" | "xlsx" | "ods" | "csv")
|
||||
) {
|
||||
"sheet"
|
||||
} else if is_web_file_name(&lower) {
|
||||
"web"
|
||||
} else if is_config_file_name(&lower) {
|
||||
"config"
|
||||
} else if is_code_file_name(&lower) {
|
||||
"code"
|
||||
} else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) {
|
||||
"book"
|
||||
} else {
|
||||
@@ -7756,6 +7798,135 @@ fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_web_file_name(file_name: &str) -> bool {
|
||||
matches!(
|
||||
extension(file_name).as_deref(),
|
||||
Some(
|
||||
"html"
|
||||
| "htm"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "less"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "mjs"
|
||||
| "cjs"
|
||||
| "vue"
|
||||
| "svelte"
|
||||
| "astro"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_config_file_name(file_name: &str) -> bool {
|
||||
let code_file_names = [
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
".env",
|
||||
".eslintrc",
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
".npmrc",
|
||||
".prettierrc",
|
||||
"dockerfile",
|
||||
"containerfile",
|
||||
"makefile",
|
||||
"cmakelists.txt",
|
||||
"gemfile",
|
||||
"rakefile",
|
||||
"procfile",
|
||||
];
|
||||
code_file_names.contains(&file_name)
|
||||
|| matches!(
|
||||
extension(file_name).as_deref(),
|
||||
Some(
|
||||
"json"
|
||||
| "jsonc"
|
||||
| "json5"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "ini"
|
||||
| "env"
|
||||
| "xml"
|
||||
| "lock"
|
||||
| "hcl"
|
||||
| "tf"
|
||||
| "tfvars"
|
||||
| "nix"
|
||||
| "properties"
|
||||
| "conf"
|
||||
| "cfg"
|
||||
| "config"
|
||||
| "service"
|
||||
| "desktop"
|
||||
| "gitignore"
|
||||
| "gitattributes"
|
||||
| "editorconfig"
|
||||
| "npmrc"
|
||||
| "prettierrc"
|
||||
| "eslintrc"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_code_file_name(file_name: &str) -> bool {
|
||||
matches!(
|
||||
extension(file_name).as_deref(),
|
||||
Some(
|
||||
"rs" | "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
| "cs"
|
||||
| "php"
|
||||
| "rb"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "sql"
|
||||
| "lua"
|
||||
| "dart"
|
||||
| "kt"
|
||||
| "kts"
|
||||
| "swift"
|
||||
| "scala"
|
||||
| "gradle"
|
||||
| "groovy"
|
||||
| "clj"
|
||||
| "ex"
|
||||
| "exs"
|
||||
| "erl"
|
||||
| "hrl"
|
||||
| "fs"
|
||||
| "fsx"
|
||||
| "r"
|
||||
| "jl"
|
||||
| "m"
|
||||
| "mm"
|
||||
| "pl"
|
||||
| "pm"
|
||||
| "ps1"
|
||||
| "bat"
|
||||
| "cmd"
|
||||
| "psm1"
|
||||
| "psd1"
|
||||
| "proto"
|
||||
| "graphql"
|
||||
| "gql"
|
||||
| "prisma"
|
||||
| "cmake"
|
||||
| "bazel"
|
||||
| "bzl"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn extension(file_name: &str) -> Option<String> {
|
||||
Path::new(file_name)
|
||||
.extension()
|
||||
@@ -7850,6 +8021,15 @@ fn sanitize_file_name(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_uploaded_file_name(value: &str, fallback: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
let leaf = trimmed
|
||||
.rsplit(['/', '\\'])
|
||||
.find(|part| !part.trim().is_empty())
|
||||
.unwrap_or(trimmed);
|
||||
sanitize_file_name(leaf, fallback)
|
||||
}
|
||||
|
||||
fn next_available_path(directory: &Path, stem: &str, extension: &str) -> PathBuf {
|
||||
let first = directory.join(format!("{stem}.{extension}"));
|
||||
if !first.exists() {
|
||||
@@ -8919,7 +9099,7 @@ fn system_time_ms(time: SystemTime) -> u128 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn local_markdown_conflict_detection_key(
|
||||
pub(crate) fn local_markdown_conflict_detection_key(
|
||||
document_id: &str,
|
||||
markdown_path: &Path,
|
||||
) -> Result<String, WebError> {
|
||||
@@ -9044,6 +9224,10 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
||||
LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
|
||||
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
|
||||
SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
add_sqlite_local_access_grant_for_context,
|
||||
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
|
||||
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
|
||||
@@ -9062,18 +9246,15 @@ mod tests {
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
|
||||
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
|
||||
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
|
||||
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
write_sync_conflict_report,
|
||||
};
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path as AxumPath, Query, State};
|
||||
use axum::http::{HeaderMap, Method, StatusCode};
|
||||
use axum::Json;
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
@@ -9345,28 +9526,25 @@ mod tests {
|
||||
"tableCell"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]
|
||||
["text"],
|
||||
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]["text"],
|
||||
"左"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
|
||||
["text"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["text"],
|
||||
"A"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
|
||||
["marks"][0]["type"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["marks"]
|
||||
[0]["type"],
|
||||
"code"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
|
||||
["text"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["text"],
|
||||
"B"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
|
||||
["marks"][0]["type"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["marks"]
|
||||
[0]["type"],
|
||||
"bold"
|
||||
);
|
||||
}
|
||||
@@ -9469,10 +9647,12 @@ mod tests {
|
||||
let body = serde_json::to_value(&aggregate.body).expect("body json");
|
||||
|
||||
assert_eq!(body["fileVersion"], body["conflictDetectionKey"]);
|
||||
assert!(body["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:"));
|
||||
assert!(
|
||||
body["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -9494,10 +9674,12 @@ mod tests {
|
||||
.expect("save");
|
||||
|
||||
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
||||
assert!(result["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:"));
|
||||
assert!(
|
||||
result["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -9516,6 +9698,8 @@ mod tests {
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.clone(),
|
||||
expected_file_version: aggregate.body.file_version.as_str().map(ToOwned::to_owned),
|
||||
write_intent_id: Some("intent:test:page-body-write".into()),
|
||||
save_operation_id: Some("save:test:page-body-write".into()),
|
||||
base_content_hash: Some("sha256:test-base".into()),
|
||||
content_format: "editorBlocks".into(),
|
||||
content: serde_json::json!([
|
||||
@@ -9530,6 +9714,8 @@ mod tests {
|
||||
assert_eq!(result["compatCommand"], "page.body.save");
|
||||
assert_eq!(result["contentFormat"], "editorBlocks");
|
||||
assert_eq!(result["editorSource"], "unit-test");
|
||||
assert_eq!(result["writeIntentId"], "intent:test:page-body-write");
|
||||
assert_eq!(result["saveOperationId"], "save:test:page-body-write");
|
||||
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
||||
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(saved.contains("# Written"));
|
||||
@@ -10614,10 +10800,12 @@ fn main() {}
|
||||
assert_eq!(created["grant"]["ownerUserId"], "user_owner");
|
||||
assert_eq!(created["grant"]["targetUserId"], "user_target");
|
||||
assert_eq!(created["grant"]["permission"], "write");
|
||||
assert!(created["grant"]["shareId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("folder_"));
|
||||
assert!(
|
||||
created["grant"]["shareId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("folder_")
|
||||
);
|
||||
|
||||
let outside_error = create_user_share_grant(
|
||||
Extension(request_context("user_owner", "user")),
|
||||
@@ -10887,11 +11075,13 @@ fn main() {}
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].id, link_id);
|
||||
assert_ne!(stored[0].token_hash, "visible-token");
|
||||
assert!(state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve share link")
|
||||
.is_some());
|
||||
assert!(
|
||||
state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve share link")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let (_, Json(listed)) = get_share_links(
|
||||
State(state.clone()),
|
||||
@@ -10924,11 +11114,13 @@ fn main() {}
|
||||
.expect("share revoked broadcast delta");
|
||||
assert_eq!(revoked_delta["kind"], "control_plane_event");
|
||||
assert_eq!(revoked_delta["eventType"], "control.share.revoked");
|
||||
assert!(state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve revoked share link")
|
||||
.is_none());
|
||||
assert!(
|
||||
state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve revoked share link")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.control_plane()
|
||||
@@ -11132,11 +11324,13 @@ fn main() {}
|
||||
payload["workspace"]["manifest"]["ownerId"],
|
||||
"user@example.com"
|
||||
);
|
||||
assert!(payload["workspace"]["manifest"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value.as_str() == Some("markdown_edit")));
|
||||
assert!(
|
||||
payload["workspace"]["manifest"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value.as_str() == Some("markdown_edit"))
|
||||
);
|
||||
ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri)
|
||||
.expect("owner can access managed workspace");
|
||||
|
||||
@@ -11387,11 +11581,12 @@ fn main() {}
|
||||
execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None)
|
||||
.expect("delete loose markdown");
|
||||
assert!(!root.join("Renamed.md").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("Renamed.md")
|
||||
.is_file());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("trash")
|
||||
.join("Renamed.md")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
|
||||
|
||||
let restored =
|
||||
@@ -12441,6 +12636,17 @@ fn main() {}
|
||||
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
|
||||
std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap");
|
||||
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
|
||||
std::fs::write(root.join("Page").join("sheet.xlsx"), b"xlsx").expect("write xlsx");
|
||||
std::fs::write(root.join("Page").join("slides.pptx"), b"pptx").expect("write pptx");
|
||||
std::fs::write(root.join("Page").join("main.rs"), b"fn main() {}").expect("write rs");
|
||||
std::fs::write(
|
||||
root.join("Page").join("app.tsx"),
|
||||
b"export const App = () => null;",
|
||||
)
|
||||
.expect("write tsx");
|
||||
std::fs::write(root.join("Page").join("package.json"), b"{}").expect("write package");
|
||||
std::fs::write(root.join("Page").join(".env"), b"KEY=value").expect("write env");
|
||||
std::fs::write(root.join("Page").join("config.yaml"), b"name: mnote").expect("write yaml");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let snapshot =
|
||||
@@ -12456,17 +12662,60 @@ fn main() {}
|
||||
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
|
||||
Some("mindmap")
|
||||
);
|
||||
assert_eq!(
|
||||
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
|
||||
Some("local-file:Page/思维导图123456.json")
|
||||
);
|
||||
|
||||
let office = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("report.docx"))
|
||||
.expect("office row");
|
||||
assert_eq!(office["rowKind"].as_str(), Some("asset"));
|
||||
assert_eq!(office["iconHint"].as_str(), Some("office"));
|
||||
assert_eq!(office["iconHint"].as_str(), Some("word"));
|
||||
assert_eq!(
|
||||
office["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
|
||||
Some("only_office")
|
||||
);
|
||||
assert_eq!(
|
||||
office["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
|
||||
Some("local-file:Page/report.docx")
|
||||
);
|
||||
let sheet = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("sheet.xlsx"))
|
||||
.expect("sheet row");
|
||||
assert_eq!(sheet["iconHint"].as_str(), Some("sheet"));
|
||||
let ppt = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("slides.pptx"))
|
||||
.expect("ppt row");
|
||||
assert_eq!(ppt["iconHint"].as_str(), Some("ppt"));
|
||||
let rust = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("main.rs"))
|
||||
.expect("rust row");
|
||||
assert_eq!(rust["iconHint"].as_str(), Some("code"));
|
||||
let tsx = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("app.tsx"))
|
||||
.expect("tsx row");
|
||||
assert_eq!(tsx["iconHint"].as_str(), Some("web"));
|
||||
let package_json = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("package.json"))
|
||||
.expect("package row");
|
||||
assert_eq!(package_json["iconHint"].as_str(), Some("config"));
|
||||
let env = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some(".env"))
|
||||
.expect("env row");
|
||||
assert_eq!(env["iconHint"].as_str(), Some("config"));
|
||||
let yaml = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("config.yaml"))
|
||||
.expect("yaml row");
|
||||
assert_eq!(yaml["iconHint"].as_str(), Some("config"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -12497,6 +12746,8 @@ fn main() {}
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.clone(),
|
||||
expected_file_version: None,
|
||||
write_intent_id: Some("intent:test:buffer-save".into()),
|
||||
save_operation_id: Some("save:test:buffer-save".into()),
|
||||
base_content_hash: None,
|
||||
content_format: "editorBlocks".into(),
|
||||
content: serde_json::json!([
|
||||
@@ -12875,6 +13126,30 @@ fn main() {}
|
||||
markdown_asset["ownerDocumentId"],
|
||||
"local-md:docs~2FREADME~2FREADME.md"
|
||||
);
|
||||
let windows_path_asset = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
"local-md:docs~2FREADME~2FREADME.md",
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "C:\\Users\\liaib\\Downloads\\1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx".to_string(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
.to_string(),
|
||||
bytes: b"pptx".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload windows path named pptx asset");
|
||||
assert_eq!(
|
||||
windows_path_asset["file_name"],
|
||||
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_asset["sourcePath"],
|
||||
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_asset["rootRelativePath"],
|
||||
"docs/README/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
let uploaded_asset_index =
|
||||
std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json"))
|
||||
.expect("uploaded asset index");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
|
||||
use comrak::{parse_document, Arena, Options};
|
||||
use serde_json::{json, Map, Value};
|
||||
use comrak::{Arena, Options, parse_document};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::routes::local_markdown_parser::{
|
||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -852,41 +852,55 @@ mod tests {
|
||||
.iter()
|
||||
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
.expect("home result");
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("docs/child.md")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
|
||||
assert!(home["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.starts_with(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
||||
)));
|
||||
assert!(
|
||||
home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("docs/child.md"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx"))
|
||||
);
|
||||
assert!(
|
||||
home["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.starts_with(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
||||
))
|
||||
);
|
||||
|
||||
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
|
||||
let child_search = query_local_search_index(
|
||||
@@ -915,11 +929,12 @@ mod tests {
|
||||
Some("local-mdid:child-page")
|
||||
);
|
||||
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists()
|
||||
);
|
||||
let mindmap_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -931,19 +946,19 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("mindmap projection");
|
||||
assert!(mindmap_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path
|
||||
assert!(
|
||||
mindmap_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
||||
&& item["publicPath"].as_str().is_some_and(|path| path
|
||||
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.starts_with("/tree?"))));
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.starts_with("/tree?")))
|
||||
);
|
||||
let office_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -955,12 +970,14 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("office projection");
|
||||
assert!(office_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||
&& item["path"].as_str() == Some("office/report.xlsx")));
|
||||
assert!(
|
||||
office_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||
&& item["path"].as_str() == Some("office/report.xlsx"))
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -987,10 +1004,12 @@ mod tests {
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read index")
|
||||
.expect("index exists");
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md"));
|
||||
assert!(
|
||||
index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
let child = index
|
||||
.documents
|
||||
.iter()
|
||||
@@ -1006,14 +1025,18 @@ mod tests {
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read index")
|
||||
.expect("index exists");
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/child.md"));
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md"));
|
||||
assert!(
|
||||
!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/child.md")
|
||||
);
|
||||
assert!(
|
||||
index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,24 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
|
||||
use crate::transport::convex::{
|
||||
execute_convex_mutation_by_name, execute_convex_query_by_name,
|
||||
execute_retired_mutation_by_name, execute_retired_query_by_name,
|
||||
persist_runtime_command_artifacts,
|
||||
};
|
||||
use axum::extract::{Multipart, Query, State};
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::http::{HeaderMap, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
build_runtime_command_artifact_plan,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
|
||||
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||
return actor_id.to_string();
|
||||
}
|
||||
if let Ok(user) = execute_convex_query_by_name(
|
||||
if let Ok(user) = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"users:currentUser",
|
||||
@@ -360,7 +360,7 @@ pub async fn upload(
|
||||
) -> Result<Response, WebError> {
|
||||
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let upload_url = execute_convex_mutation_by_name(
|
||||
let upload_url = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:generateUploadUrl",
|
||||
@@ -421,7 +421,7 @@ pub async fn upload(
|
||||
.map(|value| format!("mindmaps/{value}"));
|
||||
let id = new_asset_id();
|
||||
let kind = asset_type(&file.content_type);
|
||||
let created = execute_convex_mutation_by_name(
|
||||
let created = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:createWithStorage",
|
||||
@@ -541,7 +541,7 @@ pub async fn sign(
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?;
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let asset = execute_convex_query_by_name(
|
||||
let asset = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:getById",
|
||||
@@ -557,7 +557,7 @@ pub async fn sign(
|
||||
"资源不存在",
|
||||
));
|
||||
}
|
||||
let refreshed = execute_convex_mutation_by_name(
|
||||
let refreshed = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:refreshUrl",
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
|
||||
read_local_mindmap_data, write_local_mindmap_data,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
fetch_documents_meta_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
||||
fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeQueryEnvelopeWire, RuntimeSourceWire,
|
||||
RuntimeTargetWire, apply_mindmap_kernel_commands_to_value,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
|
||||
@@ -122,11 +123,14 @@ async fn resolve_mindmap_workspace_id(
|
||||
|
||||
// 思维导图 runtime 的历史请求体不一定带 workspaceId;
|
||||
// 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。
|
||||
let meta = fetch_documents_meta_via_convex(state.config(), context, None, document_id).await?;
|
||||
let meta =
|
||||
fetch_documents_meta_via_legacy_cloud(state.config(), context, None, document_id).await?;
|
||||
Ok(read_workspace_id_from_meta(&meta))
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
fn execution_artifacts_json(
|
||||
execution: &crate::transport::convex::RetiredCloudCommandExecution,
|
||||
) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
@@ -175,7 +179,7 @@ pub async fn get_mindmap(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
|
||||
let query_name = resolve_query_name(¶ms);
|
||||
let result = execute_runtime_query_via_convex(
|
||||
let result = execute_runtime_query_via_legacy_cloud(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -357,7 +361,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -378,7 +382,7 @@ pub async fn apply_mindmap_command(
|
||||
));
|
||||
}
|
||||
|
||||
let current = fetch_query_data_via_convex(
|
||||
let current = fetch_query_data_via_legacy_cloud(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -439,7 +443,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -465,10 +469,10 @@ pub async fn apply_mindmap_command(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ pub async fn mindmap_object_shell(
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id
|
||||
},
|
||||
"sourceKind": source_kind.unwrap_or("convex_workspace"),
|
||||
"sourceKind": source_kind.unwrap_or("local_folder"),
|
||||
"rootUri": root_uri.unwrap_or(""),
|
||||
"revision": serde_json::Value::Null,
|
||||
"conflictDetectionKey": serde_json::Value::Null,
|
||||
@@ -167,7 +167,7 @@ pub async fn mindmap_object_shell(
|
||||
"shell": "mindmap",
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"sourceKind": source_kind.unwrap_or("convex_workspace"),
|
||||
"sourceKind": source_kind.unwrap_or("local_folder"),
|
||||
"rootUri": root_uri.unwrap_or(""),
|
||||
"projection": {
|
||||
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
|
||||
@@ -217,7 +217,7 @@ pub async fn mindmap_object_shell(
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&doc_id),
|
||||
escape_html(&mindmap_id),
|
||||
escape_html(source_kind.unwrap_or("convex_workspace")),
|
||||
escape_html(source_kind.unwrap_or("local_folder")),
|
||||
escape_html(root_uri.unwrap_or("")),
|
||||
body_content,
|
||||
escape_script_json(&editor_bootstrap_json),
|
||||
@@ -326,8 +326,8 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ mod local_search_index;
|
||||
mod media;
|
||||
mod mindmap_api;
|
||||
mod mindmap_shell;
|
||||
pub(crate) mod navigation_recent;
|
||||
mod onlyoffice;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
@@ -34,14 +35,16 @@ pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
|
||||
ensure_local_path_read_access, ensure_local_workspace_access,
|
||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::routing::{any, delete, get, post, put};
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{any, delete, get, post, put};
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let hermes_base_path = state.config().hermes_base_path.clone();
|
||||
@@ -109,6 +112,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/editor/image-placeholder.svg",
|
||||
get(web_shell::editor_image_placeholder_asset),
|
||||
)
|
||||
.route("/office-preview", get(web_shell::office_preview_page))
|
||||
.route(
|
||||
"/api/office-preview/vendor/{*asset_path}",
|
||||
get(web_shell::office_preview_vendor_asset),
|
||||
)
|
||||
.route("/pdf-preview", get(web_shell::pdf_preview_page))
|
||||
.route("/api/pdfjs/{*asset_path}", get(web_shell::pdfjs_asset))
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/resource-open-runtime.js",
|
||||
get(web_shell::resource_open_runtime_asset),
|
||||
@@ -286,6 +296,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/sidebar/shortcuts/{shortcut_id}",
|
||||
delete(sidebar_shortcuts::delete_shortcut),
|
||||
)
|
||||
.route(
|
||||
"/api/navigation/recent",
|
||||
get(navigation_recent::list_recent).post(navigation_recent::upsert_recent),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy",
|
||||
get(local_folder_source::get_local_access_policy),
|
||||
@@ -398,6 +412,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/buffer-state", get(documents::buffer_state))
|
||||
.route(
|
||||
"/api/documents/buffer-state/dirty",
|
||||
post(documents::mark_buffer_dirty),
|
||||
)
|
||||
.route("/api/documents/purge", post(documents::purge))
|
||||
.route("/api/documents/empty-trash", post(documents::empty_trash))
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
@@ -440,7 +458,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/assets/upload",
|
||||
post(local_folder_source::upload_local_markdown_asset),
|
||||
post(local_folder_source::upload_local_markdown_asset)
|
||||
.layer(DefaultBodyLimit::max(128 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/files/open",
|
||||
@@ -552,9 +571,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
mod tests {
|
||||
use super::build_router;
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use crate::context::RequestContext;
|
||||
use axum::Router;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn app(enable_debug_shell_routes: bool) -> Router {
|
||||
@@ -641,6 +663,16 @@ mod tests {
|
||||
("GET", "/api/onlyoffice/proxy?u=x"),
|
||||
("POST", "/api/onlyoffice/callback"),
|
||||
("POST", "/api/onlyoffice/forcesave"),
|
||||
(
|
||||
"GET",
|
||||
"/office-preview?fileUrl=/api/local-folder/files/open&fileType=docx",
|
||||
),
|
||||
("GET", "/api/office-preview/vendor/jszip.min.js"),
|
||||
("GET", "/api/office-preview/vendor/docx-preview.min.js"),
|
||||
("GET", "/api/office-preview/vendor/xlsx.full.min.js"),
|
||||
("GET", "/api/office-preview/vendor/pptx-preview.umd.js"),
|
||||
("GET", "/pdf-preview?fileUrl=/api/local-folder/files/open"),
|
||||
("GET", "/api/pdfjs/pdf.mjs"),
|
||||
("POST", "/api/media/upload"),
|
||||
("GET", "/api/media/sign?assetId=x"),
|
||||
("POST", "/api/tree/filetree/upload-target-preflight"),
|
||||
@@ -667,6 +699,316 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_preview_page_serves_lightweight_viewer_shell() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/office-preview?fileUrl=/api/local-folder/files/open&fileName=report.docx&fileType=docx")
|
||||
.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 bytes");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
||||
assert!(html.contains("mnote-office-preview"));
|
||||
assert!(html.contains("/api/office-preview/vendor/jszip.min.js"));
|
||||
assert!(html.contains("/api/office-preview/vendor/docx-preview.min.js"));
|
||||
assert!(html.contains("/api/office-preview/vendor/xlsx.full.min.js"));
|
||||
assert!(!html.contains("mnote-office-toolbar"));
|
||||
assert!(!html.contains("mnote-office-button"));
|
||||
assert!(!html.contains(">下载<"));
|
||||
assert!(html.contains(r#"data-page-width-content-type="word""#));
|
||||
assert!(html.contains("applyPreviewWidthPreference"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_preview_page_serves_pptx_lightweight_viewer_shell() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/office-preview?fileUrl=/api/local-folder/files/open&fileName=slides.pptx&fileType=pptx")
|
||||
.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 bytes");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
||||
assert!(html.contains(r#"data-page-width-content-type="ppt""#));
|
||||
assert!(html.contains("/api/office-preview/vendor/pptx-preview.umd.js"));
|
||||
assert!(html.contains("renderPptx"));
|
||||
assert!(html.contains("window.pptxPreview"));
|
||||
assert!(!html.contains("mnote-office-toolbar"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pdf_preview_page_does_not_render_visible_toolbar() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/pdf-preview?fileUrl=/api/local-folder/files/open&fileName=report.pdf")
|
||||
.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 bytes");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
||||
assert!(html.contains("<title>report.pdf</title>"));
|
||||
assert!(!html.contains("mnote-pdf-toolbar"));
|
||||
assert!(!html.contains("mnote-pdf-title"));
|
||||
assert!(!html.contains("mnote-pdf-button"));
|
||||
assert!(html.contains(r#"data-page-width-content-type="pdf""#));
|
||||
assert!(html.contains("applyPreviewWidthPreference"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pdfjs_asset_uses_mobile_compatible_pdfjs_4() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/pdfjs/pdf.mjs")
|
||||
.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 bytes");
|
||||
let source = String::from_utf8(body.to_vec()).expect("utf8 js");
|
||||
assert!(
|
||||
source.contains("pdfjsVersion = 4.10.38") || source.contains("version = \"4.10.38\"")
|
||||
);
|
||||
assert!(!source.contains("getOrInsertComputed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_preferences_api_updates_and_returns_effective_page_width_preferences() {
|
||||
let root_a =
|
||||
std::env::temp_dir().join(format!("mnote-page-width-a-{}", std::process::id()));
|
||||
let root_b =
|
||||
std::env::temp_dir().join(format!("mnote-page-width-b-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root_a);
|
||||
let _ = fs::remove_dir_all(&root_b);
|
||||
fs::create_dir_all(root_a.join(".mnote")).expect("root a metadata");
|
||||
fs::create_dir_all(root_b.join(".mnote")).expect("root b metadata");
|
||||
fs::write(
|
||||
root_a.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"workspace-a","ownerId":"user_page_width","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("root a manifest");
|
||||
fs::write(
|
||||
root_b.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"workspace-b","ownerId":"user_page_width","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("root b manifest");
|
||||
let root_a_uri = format!("file://{}", root_a.display());
|
||||
let root_b_uri = format!("file://{}", root_b.display());
|
||||
let router = app(false);
|
||||
let mut put_request = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/ui/preferences")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_page_width")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "workspace-a",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_a_uri,
|
||||
"documentId": "local-md:a.md",
|
||||
"updates": {
|
||||
"pageWidth.default": {"mode": "comfortable", "custom": null},
|
||||
"pageWidth.word": {"mode": "wide", "custom": null},
|
||||
"pageWidth.excel": {"mode": "full", "custom": null}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request");
|
||||
let put_context = RequestContext::from_http_parts(
|
||||
put_request.method(),
|
||||
put_request.uri(),
|
||||
put_request.headers(),
|
||||
);
|
||||
put_request.extensions_mut().insert(put_context);
|
||||
let put_response = router
|
||||
.clone()
|
||||
.oneshot(put_request)
|
||||
.await
|
||||
.expect("put response");
|
||||
let put_status = put_response.status();
|
||||
let put_body = to_bytes(put_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("put body bytes");
|
||||
let put_text = String::from_utf8(put_body.to_vec()).expect("utf8 put body");
|
||||
assert_eq!(put_status, StatusCode::OK, "{put_text}");
|
||||
|
||||
let mut get_request = Request::builder()
|
||||
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-b&sourceKind=local_folder&rootUri={}&documentId=local-md:b.md", root_b_uri))
|
||||
.header("x-mnote-actor-id", "user_page_width")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let get_context = RequestContext::from_http_parts(
|
||||
get_request.method(),
|
||||
get_request.uri(),
|
||||
get_request.headers(),
|
||||
);
|
||||
get_request.extensions_mut().insert(get_context);
|
||||
let get_response = router.oneshot(get_request).await.expect("get response");
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
let body = to_bytes(get_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json payload");
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["default"]["mode"],
|
||||
"comfortable"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["word"]["mode"],
|
||||
"wide"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["excel"]["mode"],
|
||||
"full"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["word"]["source"],
|
||||
"global"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root_a);
|
||||
let _ = fs::remove_dir_all(&root_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_preferences_api_persists_ai_preferences_per_user_and_workspace() {
|
||||
let router = app(false);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-ai-preferences-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("root metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"workspace-ai-a","ownerId":"alice_ai_pref","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("root manifest");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut put_request = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/ui/preferences")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "alice_ai_pref")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "workspace-ai-a",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:ai.md",
|
||||
"updates": {
|
||||
"ai.common.default_agent_id": "reasonix",
|
||||
"ai.common.context_refs.default_selected": {
|
||||
"current_page": true,
|
||||
"folder": true
|
||||
},
|
||||
"ai.agent.hermes.profile_id": "mnoteai"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request");
|
||||
let put_context = RequestContext::from_http_parts(
|
||||
put_request.method(),
|
||||
put_request.uri(),
|
||||
put_request.headers(),
|
||||
);
|
||||
put_request.extensions_mut().insert(put_context);
|
||||
let put_response = router
|
||||
.clone()
|
||||
.oneshot(put_request)
|
||||
.await
|
||||
.expect("put response");
|
||||
let put_status = put_response.status();
|
||||
let put_body = to_bytes(put_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("put body");
|
||||
let put_text = String::from_utf8(put_body.to_vec()).expect("put utf8");
|
||||
assert_eq!(put_status, StatusCode::OK, "{put_text}");
|
||||
|
||||
let mut alice_get = Request::builder()
|
||||
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
|
||||
.header("x-mnote-actor-id", "alice_ai_pref")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let alice_context = RequestContext::from_http_parts(
|
||||
alice_get.method(),
|
||||
alice_get.uri(),
|
||||
alice_get.headers(),
|
||||
);
|
||||
alice_get.extensions_mut().insert(alice_context);
|
||||
let alice_response = router
|
||||
.clone()
|
||||
.oneshot(alice_get)
|
||||
.await
|
||||
.expect("alice get response");
|
||||
assert_eq!(alice_response.status(), StatusCode::OK);
|
||||
let alice_body = to_bytes(alice_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("alice body");
|
||||
let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
|
||||
"reasonix"
|
||||
);
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]["folder"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
|
||||
"mnoteai"
|
||||
);
|
||||
|
||||
let mut bob_get = Request::builder()
|
||||
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
|
||||
.header("x-mnote-actor-id", "bob_ai_pref")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let bob_context =
|
||||
RequestContext::from_http_parts(bob_get.method(), bob_get.uri(), bob_get.headers());
|
||||
bob_get.extensions_mut().insert(bob_context);
|
||||
let bob_response = router.oneshot(bob_get).await.expect("bob get response");
|
||||
assert_eq!(bob_response.status(), StatusCode::OK);
|
||||
let bob_body = to_bytes(bob_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("bob body");
|
||||
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("bob json");
|
||||
assert!(
|
||||
bob_payload["result"]["aiPreferences"]
|
||||
.as_object()
|
||||
.map(|value| value.is_empty())
|
||||
.unwrap_or(false)
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnote_browser_runtime_assets_are_explicitly_mounted() {
|
||||
for path in [
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavigationRecentListQuery {
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavigationRecentUpsertRequest {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default, alias = "relative_path")]
|
||||
relative_path: Option<String>,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default, alias = "metadata_json")]
|
||||
metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_recent(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<NavigationRecentListQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let records = load_navigation_recent_records(
|
||||
&state,
|
||||
&context,
|
||||
&actor_id,
|
||||
query.kind.as_deref(),
|
||||
query.limit.unwrap_or(40),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"folders": records.iter().filter(|record| record.kind == "folder").map(recent_to_json).collect::<Vec<_>>(),
|
||||
"pages": records.iter().filter(|record| record.kind == "page").map(recent_to_json).collect::<Vec<_>>(),
|
||||
"items": records.iter().map(recent_to_json).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn upsert_recent(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<NavigationRecentUpsertRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let metadata_json = normalize_metadata_json(request.metadata_json)?;
|
||||
let source_kind = request
|
||||
.source_kind
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_else(|| "local_folder".to_string());
|
||||
let root_uri = request.root_uri.trim().to_string();
|
||||
let kind = request.kind.trim().to_string();
|
||||
let document_id = request
|
||||
.document_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let relative_path = normalize_navigation_relative_path(request.relative_path.as_deref())?
|
||||
.or_else(|| {
|
||||
document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id)
|
||||
});
|
||||
if source_kind == "local_folder" {
|
||||
let root_path = ensure_local_workspace_read_access_with_state(&state, &context, &root_uri)?;
|
||||
ensure_navigation_recent_target_exists(&root_path, &kind, relative_path.as_deref())?;
|
||||
}
|
||||
let record = state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: request.id,
|
||||
user_id: actor_id,
|
||||
workspace_id: request
|
||||
.workspace_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
kind,
|
||||
source_kind,
|
||||
root_uri,
|
||||
relative_path,
|
||||
document_id,
|
||||
title: request.title.trim().to_string(),
|
||||
metadata_json,
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"navigation_recent_upsert_failed",
|
||||
format!("写入最近访问失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"recent": recent_to_json(&record),
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn load_navigation_recent_records(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
actor_id: &str,
|
||||
kind: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Vec<NavigationRecentRecord> {
|
||||
state
|
||||
.control_plane()
|
||||
.list_navigation_recent(actor_id, kind, limit)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|record| navigation_recent_is_accessible(state, context, record))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn navigation_recent_is_accessible(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
record: &NavigationRecentRecord,
|
||||
) -> bool {
|
||||
if record.source_kind != "local_folder" {
|
||||
return true;
|
||||
}
|
||||
let Ok(root_path) =
|
||||
ensure_local_workspace_read_access_with_state(state, context, &record.root_uri)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let relative_path = if record
|
||||
.relative_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
let Ok(relative_path) = normalize_navigation_relative_path(record.relative_path.as_deref())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
relative_path
|
||||
} else {
|
||||
record
|
||||
.document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id)
|
||||
};
|
||||
let Ok(target_path) = relative_path
|
||||
.as_deref()
|
||||
.map(|path| canonical_navigation_target(&root_path, path))
|
||||
.transpose()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match record.kind.as_str() {
|
||||
"folder" => target_path
|
||||
.as_deref()
|
||||
.map(Path::is_dir)
|
||||
.unwrap_or_else(|| root_path.is_dir()),
|
||||
"page" => target_path.as_deref().map(Path::is_file).unwrap_or(false),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_navigation_recent_target_exists(
|
||||
root_path: &Path,
|
||||
kind: &str,
|
||||
relative_path: Option<&str>,
|
||||
) -> Result<(), WebError> {
|
||||
match kind {
|
||||
"folder" => {
|
||||
if let Some(path) = relative_path {
|
||||
let target = canonical_navigation_target(root_path, path)?;
|
||||
if !target.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_folder_not_found",
|
||||
"最近访问文件夹不存在",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"page" => {
|
||||
let Some(path) = relative_path else {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_page_path_required",
|
||||
"最近访问页面缺少相对路径",
|
||||
));
|
||||
};
|
||||
let target = canonical_navigation_target(root_path, path)?;
|
||||
if !target.is_file() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_page_not_found",
|
||||
"最近访问页面不存在",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
let relative_path = normalize_navigation_relative_path(Some(relative_path))?
|
||||
.as_deref()
|
||||
.map(Path::new)
|
||||
.map(|path| root_path.join(path))
|
||||
.unwrap_or_else(|| root_path.to_path_buf());
|
||||
let canonical_target = relative_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"navigation_recent_target_unavailable",
|
||||
format!("最近访问目标不可用: {error}"),
|
||||
)
|
||||
})?;
|
||||
if !canonical_target.starts_with(root_path) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_root_escape",
|
||||
"最近访问路径不能越过授权目录",
|
||||
));
|
||||
}
|
||||
Ok(canonical_target)
|
||||
}
|
||||
|
||||
fn normalize_navigation_relative_path(value: Option<&str>) -> Result<Option<String>, WebError> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let normalized = value.replace('\\', "/");
|
||||
let raw_path = Path::new(&normalized);
|
||||
if raw_path.is_absolute()
|
||||
|| raw_path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_root_escape",
|
||||
"最近访问路径不能越过授权目录",
|
||||
));
|
||||
}
|
||||
let cleaned = normalized
|
||||
.split('/')
|
||||
.filter(|segment| !segment.trim().is_empty() && *segment != ".")
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
Ok(Some(cleaned).filter(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
||||
Some(
|
||||
document_id
|
||||
.trim()
|
||||
.strip_prefix("local-md:")?
|
||||
.replace("~2F", "/"),
|
||||
)
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
|
||||
current_actor_id(state, context)
|
||||
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"navigation_recent_auth_required",
|
||||
"最近访问需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn recent_to_json(record: &NavigationRecentRecord) -> Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"userId": record.user_id,
|
||||
"workspaceId": record.workspace_id,
|
||||
"kind": record.kind,
|
||||
"sourceKind": record.source_kind,
|
||||
"rootUri": record.root_uri,
|
||||
"relativePath": record.relative_path,
|
||||
"documentId": record.document_id,
|
||||
"title": record.title,
|
||||
"status": record.status,
|
||||
"metadata": serde_json::from_str::<Value>(&record.metadata_json).unwrap_or(Value::Null),
|
||||
"visitedAt": record.visited_at,
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
"revision": record.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_metadata_json(metadata_json: Option<String>) -> Result<String, WebError> {
|
||||
let metadata = metadata_json
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| serde_json::from_str::<Value>(value))
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"navigation_recent_metadata_invalid",
|
||||
format!("最近访问 metadataJson 必须是 JSON 对象: {error}"),
|
||||
)
|
||||
})?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let normalized = if metadata.is_object() {
|
||||
metadata
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
serde_json::to_string(&normalized)
|
||||
.map_err(|error| WebError::internal(format!("最近访问 metadataJson 序列化失败: {error}")))
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl EmptyStringExt for String {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
||||
if self.trim().is_empty() {
|
||||
fallback()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn test_state() -> AppState {
|
||||
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: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn temp_root(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create temp root");
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_requires_logged_in_actor() {
|
||||
let response = build_app(test_state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_post_and_get_returns_folder_and_page_groups() {
|
||||
let root = temp_root("mnote-navigation-recent-api");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
let app = build_app(state);
|
||||
|
||||
let folder_body = json!({
|
||||
"kind": "folder",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": "docs",
|
||||
"title": "docs"
|
||||
})
|
||||
.to_string();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/navigation/recent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(folder_body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let page_body = json!({
|
||||
"kind": "page",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": "docs/Plan.md",
|
||||
"documentId": "local-md:docs~2FPlan.md",
|
||||
"title": "Plan"
|
||||
})
|
||||
.to_string();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/navigation/recent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(page_body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
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["folders"][0]["relativePath"], "docs");
|
||||
assert_eq!(payload["pages"][0]["documentId"], "local-md:docs~2FPlan.md");
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_list_filters_deleted_local_paths() {
|
||||
let root = temp_root("mnote-navigation-recent-filter-deleted");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "page".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("docs/Plan.md".to_string()),
|
||||
document_id: Some("local-md:docs~2FPlan.md".to_string()),
|
||||
title: "Plan".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert recent");
|
||||
std::fs::remove_file(root.join("docs").join("Plan.md")).expect("delete plan");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
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["pages"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_post_rejects_root_escape_relative_path() {
|
||||
let root = temp_root("mnote-navigation-recent-reject-escape");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
let app = build_app(state);
|
||||
let body = json!({
|
||||
"kind": "folder",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": "../",
|
||||
"title": "escape"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/navigation/recent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("navigation_recent_root_escape")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_list_filters_root_escape_records() {
|
||||
let root = temp_root("mnote-navigation-recent-filter-escape");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("../".to_string()),
|
||||
document_id: None,
|
||||
title: "escape".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert escaped recent through store");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
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["folders"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,29 @@ use crate::app::AppConfig;
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{
|
||||
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
|
||||
OnlyOfficeProxyPreparationInput,
|
||||
OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput, prepare_callback,
|
||||
prepare_proxy_request, sign_config,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::Engine;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::upgrade::Upgraded;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
||||
use tokio_tungstenite::tungstenite::protocol::Role;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
|
||||
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
|
||||
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
|
||||
@@ -841,8 +841,7 @@ pub async fn proxy(
|
||||
onlyoffice_storage_host_override: env_or_dotenv(
|
||||
"NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE",
|
||||
),
|
||||
convex_origin: proxy_origin_env("CONVEX_SELF_HOSTED_URL")
|
||||
.or_else(|| proxy_origin_env("NEXT_PUBLIC_CONVEX_URL")),
|
||||
convex_origin: None,
|
||||
supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
.or_else(|| env_or_dotenv("SUPABASE_ANON_KEY")),
|
||||
})
|
||||
@@ -1706,9 +1705,10 @@ mod tests {
|
||||
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
||||
let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
|
||||
assert!(key.len() <= 128);
|
||||
assert!(key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
||||
assert!(
|
||||
key.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
|
||||
);
|
||||
assert!(key.starts_with("asset_1_"));
|
||||
}
|
||||
|
||||
@@ -1722,9 +1722,10 @@ mod tests {
|
||||
);
|
||||
assert!(key.len() <= 128);
|
||||
assert!(key.starts_with("mnote_"));
|
||||
assert!(key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
||||
assert!(
|
||||
key.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
|
||||
);
|
||||
assert!(!key.contains('/'));
|
||||
assert!(!key.contains(':'));
|
||||
assert!(!key.contains('重'));
|
||||
@@ -2024,8 +2025,11 @@ mod tests {
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
|
||||
assert!(
|
||||
request.starts_with(
|
||||
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
|
||||
)
|
||||
);
|
||||
assert!(request.contains(r#""status":2"#));
|
||||
assert!(request.contains(r#""key":"doc_key""#));
|
||||
}
|
||||
@@ -2080,8 +2084,10 @@ mod tests {
|
||||
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["via"], "forcesave");
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
|
||||
assert!(
|
||||
request
|
||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2,10 +2,10 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
@@ -587,12 +587,12 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
@@ -952,14 +952,18 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("已读取第一段:第一段"));
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("测试123"));
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("已读取第一段:第一段")
|
||||
);
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("测试123")
|
||||
);
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::execute_convex_query_plan;
|
||||
use crate::transport::convex::execute_retired_query_plan;
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
|
||||
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
|
||||
RuntimeSourceWire,
|
||||
BridgeContext, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
|
||||
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, build_query_request,
|
||||
execute_runtime_input, execute_runtime_query,
|
||||
};
|
||||
use core_protocol::{GetPageMeta, QueryEnvelope};
|
||||
use serde_json::Value;
|
||||
use storage_convex_bridge::{build_query_request, BridgeContext};
|
||||
|
||||
pub fn resolve_effective_workspace_id(
|
||||
context: &RequestContext,
|
||||
@@ -155,24 +154,24 @@ pub fn build_documents_meta_query_plan(
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_query_data_via_convex(
|
||||
pub async fn fetch_query_data_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_query_plan(context, effective_workspace_id, query)?;
|
||||
execute_convex_query_plan(config, context, &plan).await
|
||||
execute_retired_query_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub async fn fetch_documents_meta_via_convex(
|
||||
pub async fn fetch_documents_meta_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?;
|
||||
execute_convex_query_plan(config, context, &plan).await
|
||||
execute_retired_query_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub fn execute_runtime_query_against_data(
|
||||
@@ -189,13 +188,14 @@ pub fn execute_runtime_query_against_data(
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_query_via_convex(
|
||||
pub async fn execute_runtime_query_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let data =
|
||||
fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
|
||||
fetch_query_data_via_legacy_cloud(config, context, effective_workspace_id, query.clone())
|
||||
.await?;
|
||||
execute_runtime_query_against_data(context, effective_workspace_id, query, data)
|
||||
}
|
||||
|
||||
@@ -2,24 +2,24 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex_with_artifacts, runtime_context,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts, runtime_context,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, execute_local_tree_command,
|
||||
};
|
||||
use crate::transport::convex::{
|
||||
execute_convex_mutation_by_name, execute_convex_query_by_name,
|
||||
execute_retired_mutation_by_name, execute_retired_query_by_name,
|
||||
persist_runtime_command_artifacts,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire,
|
||||
RuntimeTargetWire, build_runtime_command_artifact_plan,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
@@ -118,7 +118,7 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if has_auth_cookie {
|
||||
if let Ok(user) = execute_convex_query_by_name(
|
||||
if let Ok(user) = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"users:currentUser",
|
||||
@@ -372,7 +372,7 @@ async fn fetch_document_workspace_id(
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
) -> Option<String> {
|
||||
execute_convex_query_by_name(
|
||||
execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"documents:getMeta",
|
||||
@@ -401,7 +401,7 @@ async fn fetch_table_meta(
|
||||
user_id: &str,
|
||||
table_id: &str,
|
||||
) -> Option<Value> {
|
||||
execute_convex_query_by_name(
|
||||
execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"tables:get",
|
||||
@@ -479,7 +479,7 @@ async fn fetch_media_asset_meta(
|
||||
user_id: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let assets = execute_convex_query_by_name(
|
||||
let assets = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"mediaAssets:listByIds",
|
||||
@@ -606,7 +606,7 @@ pub async fn media_batch(
|
||||
body.new_name.as_deref().unwrap_or_default(),
|
||||
"newName",
|
||||
)?;
|
||||
execute_convex_mutation_by_name(
|
||||
execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:patchById",
|
||||
@@ -629,7 +629,7 @@ pub async fn media_batch(
|
||||
body.target_document_id.as_deref().unwrap_or_default(),
|
||||
"targetDocumentId",
|
||||
)?;
|
||||
execute_convex_mutation_by_name(
|
||||
execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:patchById",
|
||||
@@ -646,7 +646,7 @@ pub async fn media_batch(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let command_result = execute_runtime_command_via_convex_with_artifacts(
|
||||
let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -712,7 +712,7 @@ pub async fn media_purge(
|
||||
"assetId": asset_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -729,7 +729,7 @@ pub async fn media_empty_trash(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:emptyTrashByWorkspace",
|
||||
@@ -812,7 +812,7 @@ pub async fn mindmap_delete(
|
||||
"mindmapId": mindmap_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -897,7 +897,7 @@ pub async fn mindmap_trash_action(
|
||||
"mindmapId": mindmap_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -916,7 +916,7 @@ pub async fn mindmap_empty_trash(
|
||||
Json(body): Json<WorkspaceTrashRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mindmaps:emptyTrashByWorkspace",
|
||||
@@ -980,7 +980,7 @@ pub async fn table_create(
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
})?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"tables:create",
|
||||
@@ -1087,7 +1087,7 @@ async fn table_action(
|
||||
"tableId": table_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -1108,7 +1108,7 @@ pub async fn table_empty_trash(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"tables:emptyTrashByWorkspace",
|
||||
@@ -1148,8 +1148,8 @@ pub async fn table_empty_trash(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::Request;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -1311,11 +1311,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
||||
assert!(!root.join("Page").join("map.mindmap.json").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
|
||||
original_markdown,
|
||||
@@ -1421,11 +1422,13 @@ mod tests {
|
||||
purged_payload["result"]["canonicalCommand"],
|
||||
"tree.resource.purge"
|
||||
);
|
||||
assert!(!root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists());
|
||||
assert!(
|
||||
!root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||
use crate::routes::{local_folder_source, local_search_index};
|
||||
use crate::ssr::pages::search::SearchPage;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_QUERY_NAME: &str = "x-query-name";
|
||||
@@ -417,7 +417,7 @@ async fn load_search_results_with_filters(
|
||||
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
|
||||
}),
|
||||
};
|
||||
match execute_runtime_query_via_convex(
|
||||
match execute_runtime_query_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
@@ -546,10 +546,10 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -788,31 +788,40 @@ mod tests {
|
||||
.expect("home result");
|
||||
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
|
||||
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists());
|
||||
assert!(payload["recent"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||
assert!(
|
||||
home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
|
||||
);
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
payload["recent"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -921,11 +930,13 @@ mod tests {
|
||||
backlinks_payload["meta"]["queryName"].as_str(),
|
||||
Some("search.local_index.backlinks")
|
||||
);
|
||||
assert!(backlinks_payload["result"]["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||
assert!(
|
||||
backlinks_payload["result"]["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
);
|
||||
|
||||
let tags_response = app()
|
||||
.oneshot(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use control_plane::session_token_hash;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -213,10 +213,10 @@ fn stamp_owner_header(headers: &mut HeaderMap) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
|
||||
@@ -2,12 +2,12 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -2,11 +2,11 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, fetch_query_data_via_convex,
|
||||
execute_runtime_query_against_data, fetch_query_data_via_legacy_cloud,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionSnapshotSpec<'a> {
|
||||
@@ -38,7 +38,7 @@ pub async fn load_sidebar_dataset(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
fetch_query_data_via_convex(
|
||||
fetch_query_data_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
|
||||
@@ -2,15 +2,15 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
|
||||
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
StreamChangeKind, StreamSnapshotQuery,
|
||||
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload,
|
||||
build_stream_push_delta_hint, load_stream_overview, load_stream_snapshot,
|
||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::stream;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
@@ -340,9 +340,9 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -2,16 +2,16 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
||||
subtree_query,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
|
||||
"page.body.save",
|
||||
@@ -627,7 +627,7 @@ pub async fn load_stream_overview(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let overview = execute_runtime_query_via_convex(
|
||||
let overview = execute_runtime_query_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -670,7 +670,7 @@ pub async fn load_stream_snapshot(
|
||||
}
|
||||
};
|
||||
|
||||
let overview = execute_runtime_query_via_convex(
|
||||
let overview = execute_runtime_query_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -701,8 +701,9 @@ pub async fn load_stream_snapshot(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
||||
delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor,
|
||||
resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
||||
resolve_stream_scope,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -3,45 +3,45 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order,
|
||||
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
|
||||
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_workspace_id_from_root_uri, LocalAccessMode,
|
||||
LocalAccessMode, ensure_local_workspace_access_with_state,
|
||||
ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
||||
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
|
||||
use crate::transport::convex::execute_retired_mutation_by_name;
|
||||
use crate::tree_shell::filetree_renderer::{
|
||||
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
|
||||
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
|
||||
};
|
||||
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
||||
use crate::tree_shell::page_renderer::{
|
||||
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
|
||||
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
|
||||
};
|
||||
use crate::tree_shell::picker_renderer::{
|
||||
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
|
||||
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
|
||||
};
|
||||
use crate::tree_shell::renderer_input::{
|
||||
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
||||
TreeShellRendererInput,
|
||||
};
|
||||
use crate::tree_shell::runtime_api::{
|
||||
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
|
||||
TreeShellRuntimeResult,
|
||||
TreeShellRuntimeRequest, TreeShellRuntimeResult,
|
||||
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -137,6 +137,69 @@ impl TreeCommandEnvelopeContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TreeCompatAliasCatalogEntry {
|
||||
pub compat_command: &'static str,
|
||||
pub preferred_command: &'static str,
|
||||
pub source_kind: &'static str,
|
||||
pub retained_for: &'static str,
|
||||
pub retirement_condition: &'static str,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub const TREE_DOCUMENT_COMPAT_ALIAS_CATALOG: &[TreeCompatAliasCatalogEntry] = &[
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.create",
|
||||
preferred_command: "tree.node.create",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.create",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.title.update",
|
||||
preferred_command: "tree.node.rename",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.rename",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.move",
|
||||
preferred_command: "tree.subtree.move",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.subtree.move",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.delete",
|
||||
preferred_command: "tree.node.archive",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.archive",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.restore",
|
||||
preferred_command: "tree.node.restore",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.restore",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.purge",
|
||||
preferred_command: "tree.node.purge",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.purge",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.copy_tree",
|
||||
preferred_command: "tree.subtree.copy",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.subtree.copy",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TreeCommandRequest {
|
||||
Create {
|
||||
@@ -2074,7 +2137,7 @@ async fn resolve_tree_create_workspace_id(
|
||||
|
||||
if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let parent_meta =
|
||||
fetch_documents_meta_via_convex(state.config(), context, None, parent_id).await?;
|
||||
fetch_documents_meta_via_legacy_cloud(state.config(), context, None, parent_id).await?;
|
||||
if let Some(workspace_id) = parent_meta
|
||||
.get("workspace_id")
|
||||
.or_else(|| parent_meta.get("workspaceId"))
|
||||
@@ -2086,7 +2149,7 @@ async fn resolve_tree_create_workspace_id(
|
||||
}
|
||||
}
|
||||
|
||||
let bootstrap = execute_convex_mutation_by_name(
|
||||
let bootstrap = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"workspaces:ensureDefaultWorkspace",
|
||||
@@ -2201,7 +2264,15 @@ pub async fn tree_command(
|
||||
.with_context(&context)
|
||||
.with_header("x-error-phase", "tree_command_decode")
|
||||
})?;
|
||||
let envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request);
|
||||
let mut envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request);
|
||||
if envelope_context.source_kind.is_none()
|
||||
&& envelope_context
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.is_some_and(|root_uri| root_uri.trim().starts_with("file://"))
|
||||
{
|
||||
envelope_context.source_kind = Some("local_folder".into());
|
||||
}
|
||||
let request = match raw_request.action.trim() {
|
||||
"create" => TreeCommandRequest::Create {
|
||||
workspace_id: raw_request.workspace_id,
|
||||
@@ -2453,7 +2524,7 @@ pub async fn tree_command(
|
||||
command_wire.preflight_data =
|
||||
Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?);
|
||||
}
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -2513,10 +2584,10 @@ mod tests {
|
||||
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
|
||||
|
||||
use super::{
|
||||
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
||||
TreeCommandRequest,
|
||||
TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest,
|
||||
collect_filetree_render_rows, create_command_wire,
|
||||
};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
@@ -2540,7 +2611,7 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
|
||||
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"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(),
|
||||
@@ -2681,14 +2752,20 @@ mod tests {
|
||||
assert!(html.contains("data-rust-action=\"toggle\""));
|
||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
|
||||
@@ -2713,8 +2790,11 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("id=\"tree-shell-state\""));
|
||||
assert!(html
|
||||
.contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""));
|
||||
assert!(
|
||||
html.contains(
|
||||
"type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
|
||||
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML"
|
||||
@@ -2749,17 +2829,23 @@ mod tests {
|
||||
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
|
||||
assert!(html.contains("tabindex=\""));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
||||
}
|
||||
|
||||
@@ -2799,12 +2885,18 @@ mod tests {
|
||||
assert!(
|
||||
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
||||
);
|
||||
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
||||
.contains("function buildFileTreeMenuTarget(context"));
|
||||
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS
|
||||
.contains("function createTreeShellFileTreeDndRuntime(context)"));
|
||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS
|
||||
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||
assert!(
|
||||
TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
||||
.contains("function buildFileTreeMenuTarget(context")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_FILETREE_DND_RUNTIME_JS
|
||||
.contains("function createTreeShellFileTreeDndRuntime(context)")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RENDER_RUNTIME_JS
|
||||
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")
|
||||
);
|
||||
@@ -2812,8 +2904,10 @@ mod tests {
|
||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null"));
|
||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3026,8 +3120,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint(
|
||||
) {
|
||||
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint()
|
||||
{
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
@@ -3142,11 +3236,12 @@ mod tests {
|
||||
String::from_utf8_lossy(&move_body)
|
||||
);
|
||||
assert!(!root.join("重命名页面").exists());
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists()
|
||||
);
|
||||
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
|
||||
let moved_document_id = move_payload["result"]["documentId"]
|
||||
.as_str()
|
||||
@@ -3186,11 +3281,12 @@ mod tests {
|
||||
.as_str()
|
||||
.expect("copied document id")
|
||||
.to_string();
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面 2")
|
||||
.join("重命名页面 2.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("重命名页面 2")
|
||||
.join("重命名页面 2.md")
|
||||
.exists()
|
||||
);
|
||||
|
||||
let folder_response = app()
|
||||
.oneshot(
|
||||
@@ -3223,12 +3319,13 @@ mod tests {
|
||||
.expect("response");
|
||||
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||
assert!(!root.join("docs").join("重命名页面").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("trash")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists()
|
||||
);
|
||||
assert!(root.join(".mnote").join("trash-index.json").exists());
|
||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||
|
||||
@@ -3255,11 +3352,12 @@ mod tests {
|
||||
"{}",
|
||||
String::from_utf8_lossy(&restore_body)
|
||||
);
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists()
|
||||
);
|
||||
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
||||
assert_eq!(
|
||||
restore_payload["result"]["documentId"].as_str(),
|
||||
@@ -3353,7 +3451,21 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(restore_response.status(), StatusCode::OK);
|
||||
let restore_status = restore_response.status();
|
||||
let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("restore body");
|
||||
assert_eq!(
|
||||
restore_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&restore_body)
|
||||
);
|
||||
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
||||
assert_eq!(
|
||||
restore_payload["result"]["execution"]["canonicalCommand"],
|
||||
"tree.resource.restore"
|
||||
);
|
||||
assert!(root.join("docs").join("photo.png").exists());
|
||||
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
|
||||
|
||||
@@ -3364,13 +3476,28 @@ mod tests {
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
||||
r#"{{"action":"delete","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(delete_again_response.status(), StatusCode::OK);
|
||||
let delete_again_status = delete_again_response.status();
|
||||
let delete_again_body = axum::body::to_bytes(delete_again_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("delete again body");
|
||||
assert_eq!(
|
||||
delete_again_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&delete_again_body)
|
||||
);
|
||||
let delete_again_payload: Value =
|
||||
serde_json::from_slice(&delete_again_body).expect("delete again json");
|
||||
assert_eq!(
|
||||
delete_again_payload["result"]["execution"]["canonicalCommand"],
|
||||
"tree.resource.archive"
|
||||
);
|
||||
|
||||
let purge_response = app()
|
||||
.oneshot(
|
||||
@@ -3385,7 +3512,21 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(purge_response.status(), StatusCode::OK);
|
||||
let purge_status = purge_response.status();
|
||||
let purge_body = axum::body::to_bytes(purge_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("purge body");
|
||||
assert_eq!(
|
||||
purge_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&purge_body)
|
||||
);
|
||||
let purge_payload: Value = serde_json::from_slice(&purge_body).expect("purge json");
|
||||
assert_eq!(
|
||||
purge_payload["result"]["execution"]["canonicalCommand"],
|
||||
"tree.resource.purge"
|
||||
);
|
||||
assert!(!root.join("docs").join("photo.png").exists());
|
||||
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
|
||||
let trash_index_after =
|
||||
@@ -3432,10 +3573,12 @@ mod tests {
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "local_folder_root_escape");
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("root"));
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("root")
|
||||
);
|
||||
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
|
||||
assert_eq!(
|
||||
headers
|
||||
@@ -3604,8 +3747,10 @@ mod tests {
|
||||
assert!(filetree_html.contains(
|
||||
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
||||
));
|
||||
assert!(filetree_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
||||
assert!(
|
||||
filetree_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
|
||||
);
|
||||
assert!(filetree_html.contains(
|
||||
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
|
||||
));
|
||||
@@ -3633,8 +3778,10 @@ mod tests {
|
||||
assert!(picker_html.contains(
|
||||
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
||||
));
|
||||
assert!(picker_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
||||
assert!(
|
||||
picker_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4050,11 +4197,13 @@ mod tests {
|
||||
Some("convex://workspace/ws_demo")
|
||||
);
|
||||
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
|
||||
assert!(create_wire
|
||||
.source
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|capability| capability == "execute-command"));
|
||||
assert!(
|
||||
create_wire
|
||||
.source
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|capability| capability == "execute-command")
|
||||
);
|
||||
|
||||
let rename_wire = create_command_wire(
|
||||
&context,
|
||||
@@ -4242,4 +4391,42 @@ mod tests {
|
||||
assert_eq!(tree_move_plan.function_name, "documents:move");
|
||||
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
|
||||
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.compat_command.starts_with("documents."))
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.preferred_command.starts_with("tree."))
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.source_kind == "convex_workspace")
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.retained_for.contains("legacy cloud"))
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.retirement_condition.contains("emit tree."))
|
||||
);
|
||||
assert!(aliases.iter().any(|entry| {
|
||||
entry.compat_command == "documents.delete"
|
||||
&& entry.preferred_command == "tree.node.archive"
|
||||
}));
|
||||
assert!(aliases.iter().any(|entry| {
|
||||
entry.compat_command == "documents.copy_tree"
|
||||
&& entry.preferred_command == "tree.subtree.copy"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
|
||||
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
|
||||
@@ -73,7 +73,11 @@ pub(crate) async fn get_tree_view_state(
|
||||
)?;
|
||||
let preferences = state
|
||||
.control_plane()
|
||||
.list_user_ui_preferences(&actor_id, Some(&scope.workspace_id), Some(&scope.source_kind))
|
||||
.list_user_ui_preferences(
|
||||
&actor_id,
|
||||
Some(&scope.workspace_id),
|
||||
Some(&scope.source_kind),
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
|
||||
.with_context(&context)
|
||||
@@ -206,13 +210,10 @@ fn normalize_state(
|
||||
scope: &TreeViewStateScope,
|
||||
value: Value,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let mut object = value.as_object().cloned().ok_or_else(|| {
|
||||
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let schema_version = object
|
||||
.get("schemaVersion")
|
||||
.and_then(Value::as_i64)
|
||||
@@ -247,7 +248,10 @@ fn normalize_state(
|
||||
object.insert("scope".to_string(), json!(scope.scope));
|
||||
object.insert(
|
||||
"expandedIds".to_string(),
|
||||
json!(bounded_string_array(object.get("expandedIds"), MAX_STATE_ITEMS)),
|
||||
json!(bounded_string_array(
|
||||
object.get("expandedIds"),
|
||||
MAX_STATE_ITEMS
|
||||
)),
|
||||
);
|
||||
object.insert(
|
||||
"expandedRelativePaths".to_string(),
|
||||
@@ -261,11 +265,13 @@ fn normalize_state(
|
||||
}
|
||||
object.insert(
|
||||
"scrollTop".to_string(),
|
||||
json!(object
|
||||
.get("scrollTop")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0)),
|
||||
json!(
|
||||
object
|
||||
.get("scrollTop")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0)
|
||||
),
|
||||
);
|
||||
if !object.contains_key("updatedAtMs") {
|
||||
object.insert("updatedAtMs".to_string(), json!(0));
|
||||
|
||||
@@ -6,13 +6,13 @@ use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::{
|
||||
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map};
|
||||
use serde_json::{Map, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
|
||||
@@ -58,9 +58,33 @@ struct PagePreferenceScope {
|
||||
struct EffectivePagePreferences {
|
||||
scope: PagePreferenceScope,
|
||||
page_options: PageOptions,
|
||||
page_width_preferences: BTreeMap<String, EffectivePageWidthPreference>,
|
||||
ai_preferences: BTreeMap<String, Value>,
|
||||
sources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EffectivePageWidthPreference {
|
||||
mode: String,
|
||||
custom: Option<String>,
|
||||
resolved_mode: String,
|
||||
resolved_custom: Option<String>,
|
||||
css_max_width: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PageWidthPreferenceValue {
|
||||
mode: String,
|
||||
custom: Option<String>,
|
||||
}
|
||||
|
||||
const PAGE_WIDTH_CONTENT_TYPES: [&str; 7] = [
|
||||
"default", "markdown", "word", "pdf", "excel", "ppt", "mindmap",
|
||||
];
|
||||
|
||||
pub(crate) async fn effective_preferences(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -220,30 +244,57 @@ fn resolve_effective_page_preferences(
|
||||
page_options.hide_title_header = true;
|
||||
}
|
||||
let mut sources = BTreeMap::new();
|
||||
apply_preference_records(&mut page_options, &mut sources, &scope, &preferences)?;
|
||||
let mut page_width_preferences = default_page_width_preferences();
|
||||
let mut ai_preferences = BTreeMap::new();
|
||||
apply_preference_records(
|
||||
&mut page_options,
|
||||
&mut page_width_preferences,
|
||||
&mut ai_preferences,
|
||||
&mut sources,
|
||||
&scope,
|
||||
&preferences,
|
||||
)?;
|
||||
resolve_page_width_preferences(&mut page_width_preferences);
|
||||
Ok(EffectivePagePreferences {
|
||||
scope,
|
||||
page_options,
|
||||
page_width_preferences,
|
||||
ai_preferences,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_preference_records(
|
||||
page_options: &mut PageOptions,
|
||||
page_width_preferences: &mut BTreeMap<String, EffectivePageWidthPreference>,
|
||||
ai_preferences: &mut BTreeMap<String, Value>,
|
||||
sources: &mut BTreeMap<String, String>,
|
||||
scope: &PagePreferenceScope,
|
||||
preferences: &[UserUiPreferenceRecord],
|
||||
) -> Result<(), WebError> {
|
||||
for scope_kind in ["global", "source_family", "workspace", "document"] {
|
||||
let mut scope_kinds = vec![
|
||||
"global".to_string(),
|
||||
"source_family".to_string(),
|
||||
"workspace".to_string(),
|
||||
"document".to_string(),
|
||||
];
|
||||
for preference in preferences {
|
||||
if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind)
|
||||
{
|
||||
scope_kinds.push(preference.scope_kind.clone());
|
||||
}
|
||||
}
|
||||
for scope_kind in scope_kinds {
|
||||
for preference in preferences
|
||||
.iter()
|
||||
.filter(|preference| preference.scope_kind.trim() == scope_kind)
|
||||
.filter(|preference| preference.scope_kind.trim() == scope_kind.as_str())
|
||||
{
|
||||
let scope_matches = match scope_kind {
|
||||
let scope_matches = match scope_kind.as_str() {
|
||||
"global" => preference.scope_id.trim() == "default",
|
||||
"source_family" => preference.scope_id.trim() == scope.source_family,
|
||||
"workspace" => preference.scope_id.trim() == scope.workspace_id,
|
||||
"document" => preference.scope_id.trim() == scope.document_id,
|
||||
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
|
||||
_ => false,
|
||||
};
|
||||
if !scope_matches {
|
||||
@@ -255,6 +306,22 @@ fn apply_preference_records(
|
||||
preference.key
|
||||
))
|
||||
})?;
|
||||
if preference.key.starts_with("ai.common.") || preference.key.starts_with("ai.agent.") {
|
||||
ai_preferences.insert(preference.key.clone(), value);
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some(content_type) = page_width_content_type_for_key(&preference.key) {
|
||||
if let Some(normalized) = normalize_page_width_preference(content_type, &value) {
|
||||
if let Some(preference_value) = page_width_preferences.get_mut(content_type) {
|
||||
preference_value.mode = normalized.mode;
|
||||
preference_value.custom = normalized.custom;
|
||||
preference_value.source = scope_kind.to_string();
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if apply_page_option_value(page_options, &preference.key, &value) {
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
}
|
||||
@@ -291,6 +358,19 @@ fn page_preference_scope(
|
||||
}
|
||||
|
||||
fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.starts_with("ai.common.") {
|
||||
return Some(("ai.common".to_string(), scope.workspace_id.clone()));
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("ai.agent.") {
|
||||
let agent = rest.split('.').next().unwrap_or_default().trim();
|
||||
if !agent.is_empty() {
|
||||
return Some((format!("ai.agent.{agent}"), scope.workspace_id.clone()));
|
||||
}
|
||||
}
|
||||
if page_width_content_type_for_key(key).is_some() {
|
||||
return Some(("global".to_string(), "default".to_string()));
|
||||
}
|
||||
match key.trim() {
|
||||
"hideTitleHeader" | "hide_title_header" => {
|
||||
Some(("source_family".to_string(), scope.source_family.clone()))
|
||||
@@ -321,7 +401,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
|
||||
}
|
||||
|
||||
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
|
||||
Some(workspace_id.to_string())
|
||||
} else {
|
||||
None
|
||||
@@ -329,7 +409,7 @@ fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Optio
|
||||
}
|
||||
|
||||
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
|
||||
Some(source_kind.to_string())
|
||||
} else {
|
||||
None
|
||||
@@ -338,6 +418,10 @@ fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Opti
|
||||
|
||||
fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({}));
|
||||
let page_width_preferences =
|
||||
serde_json::to_value(&effective.page_width_preferences).unwrap_or_else(|_| json!({}));
|
||||
let ai_preferences =
|
||||
serde_json::to_value(&effective.ai_preferences).unwrap_or_else(|_| json!({}));
|
||||
let sources = effective
|
||||
.sources
|
||||
.into_iter()
|
||||
@@ -354,6 +438,8 @@ fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
"documentId": effective.scope.document_id,
|
||||
},
|
||||
"pageOptions": page_options,
|
||||
"pageWidthPreferences": page_width_preferences,
|
||||
"aiPreferences": ai_preferences,
|
||||
"sources": Value::Object(sources),
|
||||
}
|
||||
})
|
||||
@@ -474,6 +560,127 @@ fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value)
|
||||
false
|
||||
}
|
||||
|
||||
fn page_width_content_type_for_key(key: &str) -> Option<&'static str> {
|
||||
let content_type = key.trim().strip_prefix("pageWidth.")?;
|
||||
PAGE_WIDTH_CONTENT_TYPES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|candidate| *candidate == content_type)
|
||||
}
|
||||
|
||||
fn default_page_width_preferences() -> BTreeMap<String, EffectivePageWidthPreference> {
|
||||
PAGE_WIDTH_CONTENT_TYPES
|
||||
.into_iter()
|
||||
.map(|content_type| {
|
||||
let mode = match content_type {
|
||||
"default" => "comfortable",
|
||||
"markdown" => "readable",
|
||||
"word" | "pdf" | "ppt" => "wide",
|
||||
"excel" | "mindmap" => "full",
|
||||
_ => "comfortable",
|
||||
};
|
||||
(
|
||||
content_type.to_string(),
|
||||
EffectivePageWidthPreference {
|
||||
mode: mode.to_string(),
|
||||
custom: None,
|
||||
resolved_mode: mode.to_string(),
|
||||
resolved_custom: None,
|
||||
css_max_width: page_width_css_max_width(mode).to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_page_width_preference(
|
||||
content_type: &str,
|
||||
value: &Value,
|
||||
) -> Option<PageWidthPreferenceValue> {
|
||||
let mode = value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("mode"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|mode| !mode.is_empty())?;
|
||||
let mode = if page_width_mode_is_supported(mode) {
|
||||
mode
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let mode = if content_type == "default" && mode == "inherit" {
|
||||
"comfortable"
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
let custom = value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("custom"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|custom| !custom.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
Some(PageWidthPreferenceValue {
|
||||
mode: mode.to_string(),
|
||||
custom,
|
||||
})
|
||||
}
|
||||
|
||||
fn page_width_mode_is_supported(mode: &str) -> bool {
|
||||
matches!(
|
||||
mode,
|
||||
"inherit" | "readable" | "comfortable" | "wide" | "full" | "custom"
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_page_width_preferences(
|
||||
preferences: &mut BTreeMap<String, EffectivePageWidthPreference>,
|
||||
) {
|
||||
let default_mode = preferences
|
||||
.get("default")
|
||||
.map(|preference| preference.mode.as_str())
|
||||
.filter(|mode| *mode != "inherit")
|
||||
.unwrap_or("comfortable")
|
||||
.to_string();
|
||||
let default_custom = preferences
|
||||
.get("default")
|
||||
.and_then(|preference| preference.custom.clone());
|
||||
|
||||
for content_type in PAGE_WIDTH_CONTENT_TYPES {
|
||||
let Some(preference) = preferences.get_mut(content_type) else {
|
||||
continue;
|
||||
};
|
||||
let resolved_mode = if content_type != "default" && preference.mode == "inherit" {
|
||||
default_mode.clone()
|
||||
} else if preference.mode == "inherit" {
|
||||
"comfortable".to_string()
|
||||
} else {
|
||||
preference.mode.clone()
|
||||
};
|
||||
let resolved_custom = if content_type != "default" && preference.mode == "inherit" {
|
||||
default_custom.clone()
|
||||
} else {
|
||||
preference.custom.clone()
|
||||
};
|
||||
preference.resolved_mode = resolved_mode.clone();
|
||||
preference.resolved_custom = resolved_custom;
|
||||
preference.css_max_width = page_width_css_max_width(&resolved_mode).to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn page_width_css_max_width(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"readable" => "760px",
|
||||
"comfortable" => "980px",
|
||||
"wide" => "1180px",
|
||||
"full" => "none",
|
||||
"custom" => "980px",
|
||||
_ => "980px",
|
||||
}
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,13 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
|
||||
StreamSnapshotQuery, build_stream_push_delta_hint, load_stream_snapshot,
|
||||
};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::Response;
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
pub async fn socket(
|
||||
|
||||
Reference in New Issue
Block a user