feat: cut over rust web main shell
This commit is contained in:
@@ -8450,16 +8450,6 @@ fn execute_command(
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"id": payload.document_id,
|
||||
"streamDeltaHint": tree_stream_delta_hint("remove_document", json!({
|
||||
"documentId": payload.document_id,
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.node.purged"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.node.purged",
|
||||
tree_stream_delta_hint("remove_document", json!({
|
||||
"documentId": payload.document_id,
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -10991,31 +10981,7 @@ mod tests {
|
||||
"documentId": "doc_1",
|
||||
}),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "remove_document",
|
||||
"args": {
|
||||
"documentId": "doc_1"
|
||||
}
|
||||
},
|
||||
"domainEventHint": {
|
||||
"family": "tree",
|
||||
"eventType": "tree.node.purged"
|
||||
},
|
||||
"domainEventPlan": {
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "tree.node.purged",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "remove_document",
|
||||
"args": {
|
||||
"documentId": "doc_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"id": "doc_1"
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -11,6 +11,9 @@ pub struct AppConfig {
|
||||
pub service_name: String,
|
||||
pub service_version: String,
|
||||
pub bind_addr: String,
|
||||
pub public_bind_addr: String,
|
||||
pub legacy_next_base_url: Option<String>,
|
||||
pub enable_legacy_next_compat: bool,
|
||||
pub enable_debug_shell_routes: bool,
|
||||
pub hermes_base_path: String,
|
||||
pub compat_next_base_path: String,
|
||||
@@ -30,7 +33,16 @@ impl AppConfig {
|
||||
service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
|
||||
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
|
||||
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
|
||||
bind_addr: env::var("MNOTE_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:0".into()),
|
||||
bind_addr: env::var("MNOTE_WEB_BIND")
|
||||
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
|
||||
.unwrap_or_else(|_| "127.0.0.1:0".into()),
|
||||
public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND")
|
||||
.unwrap_or_else(|_| "127.0.0.1:3000".into()),
|
||||
legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL")
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", true),
|
||||
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
@@ -79,6 +91,13 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_bool(key: &str, default: bool) -> bool {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = env::var(key) {
|
||||
let trimmed = value.trim().trim_matches('"').to_string();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::context::RequestContext;
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -2,8 +2,11 @@ pub mod app;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod middleware;
|
||||
pub mod page_aggregate;
|
||||
pub mod routes;
|
||||
pub mod ssr;
|
||||
pub mod transport;
|
||||
pub mod tree_shell;
|
||||
pub mod workspace_shell;
|
||||
|
||||
pub use app::{build_app, AppConfig, AppState};
|
||||
|
||||
@@ -8,10 +8,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
let bind_addr = config.bind_addr.clone();
|
||||
let public_bind_addr = config.public_bind_addr.clone();
|
||||
let app = build_app(AppState::new(config));
|
||||
let listener = TcpListener::bind(&bind_addr).await?;
|
||||
|
||||
info!(bind_addr = %bind_addr, "mnote-web 最小骨架已启动");
|
||||
info!(bind_addr = %bind_addr, public_bind_addr = %public_bind_addr, "mnote-web gateway 已启动");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 类型安全的 Page Aggregate 结构体,替代 serde_json::Value 的临时实现。
|
||||
//!
|
||||
//! 参考契约文档: design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md
|
||||
|
||||
pub mod builder;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Page Aggregate 根结构
|
||||
///
|
||||
/// 对应 JSON 契约:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "schema": "mnote.page_aggregate.v1",
|
||||
/// "identity": { ... },
|
||||
/// "head": { ... },
|
||||
/// "layout": { ... },
|
||||
/// "body": { ... },
|
||||
/// "tree": { ... },
|
||||
/// "stats": { ... }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageAggregate {
|
||||
pub schema: String,
|
||||
pub identity: PageIdentity,
|
||||
pub head: PageHead,
|
||||
pub layout: PageLayout,
|
||||
pub body: PageBody,
|
||||
pub tree: PageTree,
|
||||
pub stats: PageStats,
|
||||
}
|
||||
|
||||
impl PageAggregate {
|
||||
/// 创建一个新的 builder 用于构建 PageAggregate。
|
||||
pub fn builder() -> builder::PageAggregateBuilder {
|
||||
builder::PageAggregateBuilder::new()
|
||||
}
|
||||
|
||||
/// 序列化为 `serde_json::Value`,输出与当前 `build_page_aggregate_snapshot` 完全一致的 JSON。
|
||||
///
|
||||
/// 使用 `#[serde(rename_all = "camelCase")]` 确保字段名与前端契约一致。
|
||||
pub fn to_json_value(&self) -> Value {
|
||||
serde_json::to_value(self).expect("PageAggregate 序列化不应失败")
|
||||
}
|
||||
|
||||
/// 获取页面标题(`head.title`)的便捷方法。
|
||||
pub fn head_title(&self) -> &str {
|
||||
&self.head.title
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面身份标识。
|
||||
///
|
||||
/// 标识这份页面聚合属于哪一个 page aggregate。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageIdentity {
|
||||
pub document_id: String,
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
/// 页面头部正式真相。
|
||||
///
|
||||
/// 承载页面头部的 title、updatedAt 及权限信息。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageHead {
|
||||
pub title: String,
|
||||
pub updated_at: Value,
|
||||
pub permissions: PagePermissions,
|
||||
}
|
||||
|
||||
/// 页面权限。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PagePermissions {
|
||||
pub read_only: bool,
|
||||
pub disable_download: bool,
|
||||
pub disable_copy: bool,
|
||||
}
|
||||
|
||||
/// 页面布局。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageLayout {
|
||||
pub page_options: PageOptions,
|
||||
}
|
||||
|
||||
/// 页面选项 / 编辑器运行时设置。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageOptions {
|
||||
pub wide_layout: bool,
|
||||
pub small_text: bool,
|
||||
pub layout_density: String,
|
||||
pub show_heading_numbers: bool,
|
||||
pub show_toc: bool,
|
||||
pub show_structure: bool,
|
||||
pub protect_editing: bool,
|
||||
pub show_word_count: bool,
|
||||
pub collapse_backlinks: bool,
|
||||
pub page_font: String,
|
||||
pub hide_child_pages: bool,
|
||||
pub show_block_ref_count: bool,
|
||||
pub embed_default_block_id: Value,
|
||||
}
|
||||
|
||||
/// 页面正文内容与保存元数据。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageBody {
|
||||
pub content: Value,
|
||||
pub revision: Value,
|
||||
pub conflict_detection_key: Value,
|
||||
}
|
||||
|
||||
/// 当前页面对应的子树投影。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageTree {
|
||||
pub page_subtree: Value,
|
||||
}
|
||||
|
||||
/// 页面统计附属投影。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageStats {
|
||||
pub word_count: u64,
|
||||
pub character_count: u64,
|
||||
pub block_count: u64,
|
||||
pub todo_total: u64,
|
||||
pub todo_done: u64,
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! PageAggregate 构建器(Builder 模式)。
|
||||
//!
|
||||
//! 使用方法:
|
||||
//! ```rust,ignore
|
||||
//! let aggregate = PageAggregate::builder()
|
||||
//! .document_id("doc_1")
|
||||
//! .workspace_id("ws_demo")
|
||||
//! .title("我的页面")
|
||||
//! .build();
|
||||
//! ```
|
||||
|
||||
use crate::page_aggregate::{
|
||||
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
|
||||
PagePermissions, PageStats, PageTree,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
/// PageAggregate 构建器。
|
||||
///
|
||||
/// 所有字段都有与当前 `build_page_aggregate_snapshot` 一致的默认值。
|
||||
/// 使用 `build()` 方法消费 builder 并产出 `PageAggregate`。
|
||||
#[derive(Debug)]
|
||||
pub struct PageAggregateBuilder {
|
||||
document_id: String,
|
||||
workspace_id: String,
|
||||
title: String,
|
||||
updated_at: Value,
|
||||
read_only: bool,
|
||||
disable_download: bool,
|
||||
disable_copy: bool,
|
||||
wide_layout: bool,
|
||||
small_text: bool,
|
||||
layout_density: String,
|
||||
show_heading_numbers: bool,
|
||||
show_toc: bool,
|
||||
show_structure: bool,
|
||||
protect_editing: bool,
|
||||
show_word_count: bool,
|
||||
collapse_backlinks: bool,
|
||||
page_font: String,
|
||||
hide_child_pages: bool,
|
||||
show_block_ref_count: bool,
|
||||
embed_default_block_id: Value,
|
||||
content: Value,
|
||||
revision: Value,
|
||||
conflict_detection_key: Value,
|
||||
page_subtree: Value,
|
||||
word_count: u64,
|
||||
character_count: u64,
|
||||
block_count: u64,
|
||||
todo_total: u64,
|
||||
todo_done: u64,
|
||||
}
|
||||
|
||||
impl PageAggregateBuilder {
|
||||
/// 创建新的构建器,所有字段使用与当前 `build_page_aggregate_snapshot` 一致的默认值。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
document_id: String::new(),
|
||||
workspace_id: "default".to_string(),
|
||||
title: "无标题".to_string(),
|
||||
updated_at: Value::Null,
|
||||
read_only: false,
|
||||
disable_download: false,
|
||||
disable_copy: false,
|
||||
wide_layout: false,
|
||||
small_text: false,
|
||||
layout_density: "normal".to_string(),
|
||||
show_heading_numbers: true,
|
||||
show_toc: false,
|
||||
show_structure: false,
|
||||
protect_editing: false,
|
||||
show_word_count: true,
|
||||
collapse_backlinks: false,
|
||||
page_font: "default".to_string(),
|
||||
hide_child_pages: false,
|
||||
show_block_ref_count: false,
|
||||
embed_default_block_id: Value::Null,
|
||||
content: Value::Null,
|
||||
revision: Value::Null,
|
||||
conflict_detection_key: Value::Null,
|
||||
page_subtree: Value::Null,
|
||||
word_count: 0,
|
||||
character_count: 0,
|
||||
block_count: 0,
|
||||
todo_total: 0,
|
||||
todo_done: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
|
||||
pub fn document_id(mut self, value: impl Into<String>) -> Self {
|
||||
self.document_id = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn workspace_id(mut self, value: impl Into<String>) -> Self {
|
||||
self.workspace_id = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
// ── Head ──
|
||||
|
||||
pub fn title(mut self, value: impl Into<String>) -> Self {
|
||||
self.title = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn updated_at(mut self, value: Value) -> Self {
|
||||
self.updated_at = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn read_only(mut self, value: bool) -> Self {
|
||||
self.read_only = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disable_download(mut self, value: bool) -> Self {
|
||||
self.disable_download = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disable_copy(mut self, value: bool) -> Self {
|
||||
self.disable_copy = value;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Layout / PageOptions ──
|
||||
|
||||
pub fn wide_layout(mut self, value: bool) -> Self {
|
||||
self.wide_layout = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn small_text(mut self, value: bool) -> Self {
|
||||
self.small_text = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn layout_density(mut self, value: impl Into<String>) -> Self {
|
||||
self.layout_density = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_heading_numbers(mut self, value: bool) -> Self {
|
||||
self.show_heading_numbers = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_toc(mut self, value: bool) -> Self {
|
||||
self.show_toc = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_structure(mut self, value: bool) -> Self {
|
||||
self.show_structure = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn protect_editing(mut self, value: bool) -> Self {
|
||||
self.protect_editing = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_word_count(mut self, value: bool) -> Self {
|
||||
self.show_word_count = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn collapse_backlinks(mut self, value: bool) -> Self {
|
||||
self.collapse_backlinks = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn page_font(mut self, value: impl Into<String>) -> Self {
|
||||
self.page_font = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hide_child_pages(mut self, value: bool) -> Self {
|
||||
self.hide_child_pages = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_block_ref_count(mut self, value: bool) -> Self {
|
||||
self.show_block_ref_count = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn embed_default_block_id(mut self, value: Value) -> Self {
|
||||
self.embed_default_block_id = value;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Body ──
|
||||
|
||||
pub fn content(mut self, value: Value) -> Self {
|
||||
self.content = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn revision(mut self, value: Value) -> Self {
|
||||
self.revision = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn conflict_detection_key(mut self, value: Value) -> Self {
|
||||
self.conflict_detection_key = value;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Tree ──
|
||||
|
||||
pub fn page_subtree(mut self, value: Value) -> Self {
|
||||
self.page_subtree = value;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Stats ──
|
||||
|
||||
pub fn word_count(mut self, value: u64) -> Self {
|
||||
self.word_count = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn character_count(mut self, value: u64) -> Self {
|
||||
self.character_count = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn block_count(mut self, value: u64) -> Self {
|
||||
self.block_count = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn todo_total(mut self, value: u64) -> Self {
|
||||
self.todo_total = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn todo_done(mut self, value: u64) -> Self {
|
||||
self.todo_done = value;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Build ──
|
||||
|
||||
/// 消费 builder 并产出 `PageAggregate`。
|
||||
pub fn build(self) -> PageAggregate {
|
||||
PageAggregate {
|
||||
schema: "mnote.page_aggregate.v1".to_string(),
|
||||
identity: PageIdentity {
|
||||
document_id: self.document_id,
|
||||
workspace_id: self.workspace_id,
|
||||
},
|
||||
head: PageHead {
|
||||
title: self.title,
|
||||
updated_at: self.updated_at,
|
||||
permissions: PagePermissions {
|
||||
read_only: self.read_only,
|
||||
disable_download: self.disable_download,
|
||||
disable_copy: self.disable_copy,
|
||||
},
|
||||
},
|
||||
layout: PageLayout {
|
||||
page_options: PageOptions {
|
||||
wide_layout: self.wide_layout,
|
||||
small_text: self.small_text,
|
||||
layout_density: self.layout_density,
|
||||
show_heading_numbers: self.show_heading_numbers,
|
||||
show_toc: self.show_toc,
|
||||
show_structure: self.show_structure,
|
||||
protect_editing: self.protect_editing,
|
||||
show_word_count: self.show_word_count,
|
||||
collapse_backlinks: self.collapse_backlinks,
|
||||
page_font: self.page_font,
|
||||
hide_child_pages: self.hide_child_pages,
|
||||
show_block_ref_count: self.show_block_ref_count,
|
||||
embed_default_block_id: self.embed_default_block_id,
|
||||
},
|
||||
},
|
||||
body: PageBody {
|
||||
content: self.content,
|
||||
revision: self.revision,
|
||||
conflict_detection_key: self.conflict_detection_key,
|
||||
},
|
||||
tree: PageTree {
|
||||
page_subtree: self.page_subtree,
|
||||
},
|
||||
stats: PageStats {
|
||||
word_count: self.word_count,
|
||||
character_count: self.character_count,
|
||||
block_count: self.block_count,
|
||||
todo_total: self.todo_total,
|
||||
todo_done: self.todo_done,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PageAggregateBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, 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::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -152,7 +152,7 @@ pub async fn trace(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
@@ -162,6 +162,9 @@ mod tests {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
|
||||
@@ -2,12 +2,12 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::{
|
||||
ConvexCommandExecution, execute_convex_command_plan, execute_convex_command_plan_with_artifacts,
|
||||
execute_convex_command_plan, execute_convex_command_plan_with_artifacts, ConvexCommandExecution,
|
||||
};
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||
RuntimeTargetWire, execute_runtime_input,
|
||||
RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
|
||||
use axum::Json;
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use axum::extract::Query;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -94,7 +94,7 @@ pub async fn next_sidebar(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
@@ -104,6 +104,9 @@ mod tests {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
|
||||
@@ -6,15 +6,15 @@ use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
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::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -47,19 +47,34 @@ pub struct DocumentSaveRequest {
|
||||
}
|
||||
|
||||
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
|
||||
|
||||
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
||||
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
(
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"result": result,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn stamp_documents_headers(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_DOCUMENTS_TRANSPORT.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("documents-api"));
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
let trimmed = value.trim().trim_matches('"').to_string();
|
||||
@@ -99,13 +114,8 @@ fn next_documents_base_url() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn should_proxy_via_next(context: &RequestContext) -> bool {
|
||||
context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
fn should_proxy_via_next(_context: &RequestContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn build_next_proxy_headers(
|
||||
@@ -368,33 +378,80 @@ async fn proxy_next_documents_save(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn load_document_meta_result(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
query: DocumentMetaQuery,
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id_owned = query.document_id.trim().to_string();
|
||||
if document_id_owned.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(context) {
|
||||
return proxy_next_documents_meta(
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
&document_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
fetch_documents_meta_via_convex(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
&document_id_owned,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_document_content_result(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
query: DocumentContentQuery,
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id_owned = query.document_id.trim().to_string();
|
||||
if document_id_owned.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(context) {
|
||||
return proxy_next_documents_content(
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
&document_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
execute_runtime_query_via_convex(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "documents.content.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id_owned,
|
||||
"workspaceId": effective_workspace_id,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn meta(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<DocumentMetaQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let document_id = query.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(&context) {
|
||||
let result =
|
||||
proxy_next_documents_meta(&context, effective_workspace_id.as_deref(), document_id)
|
||||
.await?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let result = fetch_documents_meta_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
document_id,
|
||||
)
|
||||
.await?;
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let result = load_document_meta_result(&state, &context, query).await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -402,35 +459,8 @@ pub async fn content(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<DocumentContentQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let document_id = query.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(&context) {
|
||||
let result =
|
||||
proxy_next_documents_content(&context, effective_workspace_id.as_deref(), document_id)
|
||||
.await?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let result = execute_runtime_query_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "documents.content.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let result = load_document_content_result(&state, &context, query).await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -438,7 +468,7 @@ pub async fn save(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentSaveRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
@@ -500,9 +530,9 @@ pub async fn save(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -511,6 +541,9 @@ mod tests {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
@@ -579,8 +612,24 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_auth_cookie_does_not_force_next_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"),
|
||||
);
|
||||
let context = crate::context::RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/documents/meta".parse::<Uri>().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
assert!(!super::should_proxy_via_next(&context));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_meta_route_returns_document_metadata() {
|
||||
async fn documents_api_meta_route_returns_document_metadata() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -592,6 +641,20 @@ mod tests {
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-documents-transport")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("documents-api")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
@@ -603,7 +666,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_content_route_returns_page_subtree() {
|
||||
async fn documents_api_content_route_returns_page_subtree() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
||||
@@ -2,14 +2,14 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::documents::{
|
||||
DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
|
||||
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1681,7 +1681,7 @@ pub async fn document_editor_shell(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<DocumentEditorShellQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let (_, meta_json) = document_meta(
|
||||
let (_, _, meta_json) = document_meta(
|
||||
State(state.clone()),
|
||||
Extension(context.clone()),
|
||||
Query(DocumentMetaQuery {
|
||||
@@ -1691,7 +1691,7 @@ pub async fn document_editor_shell(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (_, content_json) = document_content(
|
||||
let (_, _, content_json) = document_content(
|
||||
State(state),
|
||||
Extension(context),
|
||||
Query(DocumentContentQuery {
|
||||
@@ -1761,10 +1761,10 @@ pub async fn transform_runtime_snapshot(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -1772,6 +1772,9 @@ mod tests {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::web_shell::{load_sidebar_tree_html, load_workspace_shell_projection};
|
||||
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream";
|
||||
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GatewayManifest {
|
||||
ok: bool,
|
||||
owner: &'static str,
|
||||
public_entry: String,
|
||||
legacy_next_base_url: Option<String>,
|
||||
legacy_next_compat_enabled: bool,
|
||||
notes: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RootEntryQuery {
|
||||
page_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn gateway_health(State(state): State<AppState>) -> Response {
|
||||
let mut response = axum::Json(GatewayManifest {
|
||||
ok: true,
|
||||
owner: "mnote-web",
|
||||
public_entry: state.config().public_bind_addr.clone(),
|
||||
legacy_next_base_url: state.config().legacy_next_base_url.clone(),
|
||||
legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
|
||||
notes: vec![
|
||||
"3000 公开入口默认由 mnote-web gateway 拥有。",
|
||||
"Next App Router 只作为 legacy compat upstream 使用。",
|
||||
],
|
||||
})
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn auth_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response, WebError> {
|
||||
if state.config().enable_legacy_next_compat && state.config().legacy_next_base_url.is_some() {
|
||||
return legacy_next_proxy(State(state), Extension(context), request).await;
|
||||
}
|
||||
|
||||
let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage());
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MNOTE Auth</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="auth">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
content
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn root_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<RootEntryQuery>,
|
||||
) -> Response {
|
||||
let workspace_id = context
|
||||
.workspace
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.unwrap_or("ws_demo");
|
||||
let requested_page_id = query
|
||||
.page_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
|
||||
let active_page_id = requested_page_id.or(recent_page_id.as_deref());
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
active_page_id,
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
);
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_sidebar_html={workspace_sidebar_html} />
|
||||
});
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MNOTE</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
content
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn legacy_next_proxy(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response, WebError> {
|
||||
if !state.config().enable_legacy_next_compat {
|
||||
return Err(WebError::service_unavailable_code(
|
||||
"legacy_next_compat_disabled",
|
||||
"Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。",
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
|
||||
}
|
||||
|
||||
let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
|
||||
return Err(WebError::service_unavailable_code(
|
||||
"legacy_next_upstream_missing",
|
||||
"未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next 路径。",
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
|
||||
};
|
||||
|
||||
let path_and_query = request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or("/");
|
||||
let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}"))
|
||||
.map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;
|
||||
|
||||
let method = request.method().clone();
|
||||
let headers = request.headers().clone();
|
||||
let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?;
|
||||
|
||||
let upstream_origin = upstream_origin(&upstream_url);
|
||||
let mut upstream_request = client.request(method, upstream_url);
|
||||
for (name, value) in headers.iter() {
|
||||
if is_hop_by_hop_header(name.as_str()) || name == header::HOST {
|
||||
continue;
|
||||
}
|
||||
if name == header::ORIGIN {
|
||||
upstream_request = upstream_request.header(name, upstream_origin.as_str());
|
||||
continue;
|
||||
}
|
||||
if name == header::REFERER {
|
||||
let normalized_referer = normalize_legacy_referer(value, &upstream_origin);
|
||||
upstream_request = upstream_request.header(name, normalized_referer);
|
||||
continue;
|
||||
}
|
||||
upstream_request = upstream_request.header(name, value);
|
||||
}
|
||||
|
||||
let upstream_response = upstream_request
|
||||
.body(body.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"legacy_next_proxy_error",
|
||||
format!("legacy Next 请求失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
|
||||
})?;
|
||||
|
||||
let status = upstream_response.status();
|
||||
let upstream_headers = upstream_response.headers().clone();
|
||||
let body = upstream_response.bytes().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"legacy_next_proxy_body_error",
|
||||
format!("legacy Next 响应读取失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
|
||||
})?;
|
||||
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY))
|
||||
.body(Body::from(body))
|
||||
.map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?;
|
||||
for (name, value) in upstream_headers.iter() {
|
||||
if is_hop_by_hop_header(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
response.headers_mut().append(name, value.clone());
|
||||
}
|
||||
stamp_gateway_headers(response.headers_mut(), true);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
||||
context.auth.cookie_header.as_deref()?.split(';').find_map(|part| {
|
||||
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
|
||||
if cookie_name.trim() == name {
|
||||
let value = cookie_value.trim();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if legacy {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_LEGACY_UPSTREAM.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("next-app-router"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hop_by_hop_header(name: &str) -> bool {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "te"
|
||||
| "trailers"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
)
|
||||
}
|
||||
|
||||
fn upstream_origin(url: &reqwest::Url) -> String {
|
||||
let host = url.host_str().unwrap_or("127.0.0.1");
|
||||
match url.port() {
|
||||
Some(port) => format!("{}://{}:{}", url.scheme(), host, port),
|
||||
None => format!("{}://{}", url.scheme(), host),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> String {
|
||||
let referer = value.to_str().unwrap_or_default();
|
||||
let Ok(parsed) = reqwest::Url::parse(referer) else {
|
||||
return upstream_origin.to_string();
|
||||
};
|
||||
let path = parsed.path();
|
||||
let query = parsed
|
||||
.query()
|
||||
.map(|query| format!("?{query}"))
|
||||
.unwrap_or_default();
|
||||
format!("{upstream_origin}{path}{query}")
|
||||
}
|
||||
|
||||
#[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 axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
app_with_legacy_next_base_url("http://127.0.0.1:3100".into())
|
||||
}
|
||||
|
||||
fn app_with_legacy_next_base_url(legacy_next_base_url: String) -> axum::Router {
|
||||
app_with_config(legacy_next_base_url, true)
|
||||
}
|
||||
|
||||
fn app_with_config(
|
||||
legacy_next_base_url: String,
|
||||
enable_legacy_next_compat: bool,
|
||||
) -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some(legacy_next_base_url),
|
||||
enable_legacy_next_compat,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn spawn_legacy_auth_upstream() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("legacy listener");
|
||||
let addr = listener.local_addr().expect("legacy addr");
|
||||
let app = axum::Router::new().route(
|
||||
"/auth",
|
||||
get(|| async {
|
||||
Html(r#"<html><body><button>测试账号快速登录</button></body></html>"#)
|
||||
})
|
||||
.post(|| async { "auth-post-ok" }),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.expect("legacy server");
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_legacy_origin_checked_upstream() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("legacy listener");
|
||||
let addr = listener.local_addr().expect("legacy addr");
|
||||
let expected_origin = format!("http://{addr}");
|
||||
let app = axum::Router::new().route(
|
||||
"/api/auth",
|
||||
post(move |headers: HeaderMap| {
|
||||
let expected_origin = expected_origin.clone();
|
||||
async move {
|
||||
let origin = headers
|
||||
.get("origin")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
if origin != expected_origin {
|
||||
return (StatusCode::FORBIDDEN, "Invalid origin");
|
||||
}
|
||||
(StatusCode::OK, "ok")
|
||||
}
|
||||
}),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.expect("legacy server");
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_legacy_cookie_upstream() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("legacy listener");
|
||||
let addr = listener.local_addr().expect("legacy addr");
|
||||
let app = axum::Router::new().route(
|
||||
"/api/auth",
|
||||
post(|| async {
|
||||
let mut response = "ok".into_response();
|
||||
response.headers_mut().append(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"),
|
||||
);
|
||||
response.headers_mut().append(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_static(
|
||||
"__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly",
|
||||
),
|
||||
);
|
||||
response
|
||||
}),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.expect("legacy server");
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_health_declares_mnote_web_owner() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/gateway/health")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["publicEntry"], "127.0.0.1:3000");
|
||||
assert_eq!(payload["legacyNextBaseUrl"], "http://127.0.0.1:3100");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_returns_wolai_workspace_layout_contract() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
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#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
|
||||
assert!(html.contains(r#"data-testid="wolai-sidebar""#));
|
||||
assert!(html.contains(r#"data-testid="wolai-topbar""#));
|
||||
assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
|
||||
assert!(html.contains("星标置顶"));
|
||||
assert!(html.contains("我的页面"));
|
||||
assert!(html.contains("垃圾箱"));
|
||||
assert!(html.contains("模板中心"));
|
||||
assert!(!html.contains("欢迎使用 MNOTE 知识管理平台"));
|
||||
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
|
||||
}
|
||||
|
||||
#[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)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("cookie", "mnote_recent_page_id=page_child")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-node-id="page_child""#));
|
||||
assert!(html.contains(r#"class="wolai-page-row wolai-active-row" href="/documents/page_child?workspaceId=ws_demo" data-node-id="page_child""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/auth")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert!(response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.contains("text/html"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_entry_uses_legacy_next_login_ui_when_compat_enabled() {
|
||||
let legacy_base_url = spawn_legacy_auth_upstream().await;
|
||||
let response = app_with_legacy_next_base_url(legacy_base_url)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/auth")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-legacy-upstream")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("next-app-router")
|
||||
);
|
||||
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("测试账号快速登录"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_entry_proxies_post_to_legacy_next_when_compat_enabled() {
|
||||
let legacy_base_url = spawn_legacy_auth_upstream().await;
|
||||
let response = app_with_legacy_next_base_url(legacy_base_url)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/auth")
|
||||
.header("origin", "http://127.0.0.1:3000")
|
||||
.header("referer", "http://127.0.0.1:3000/auth")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-legacy-upstream")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("next-app-router")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert_eq!(text, "auth-post-ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_proxy_normalizes_auth_post_origin_to_upstream_origin() {
|
||||
let legacy_base_url = spawn_legacy_origin_checked_upstream().await;
|
||||
let response = app_with_legacy_next_base_url(legacy_base_url)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth")
|
||||
.header("origin", "http://127.0.0.1:3000")
|
||||
.header("referer", "http://127.0.0.1:3000/auth")
|
||||
.body(Body::from(r#"{"action":"auth:signIn"}"#))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-legacy-upstream")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("next-app-router")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_proxy_preserves_multiple_set_cookie_headers() {
|
||||
let legacy_base_url = spawn_legacy_cookie_upstream().await;
|
||||
let response = app_with_legacy_next_base_url(legacy_base_url)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cookies = response.headers().get_all("set-cookie");
|
||||
let values = cookies
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthJWT=jwt-demo")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
|
||||
execute_runtime_query, runtime_input_requests_result,
|
||||
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||
runtime_input_requests_result, RuntimeInput,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -37,7 +40,7 @@ pub async fn health(
|
||||
pub async fn bridge_runtime(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(runtime_input): Json<RuntimeInput>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let payload = if runtime_input_requests_result(&runtime_input) {
|
||||
match execute_runtime_query(runtime_input) {
|
||||
Ok(result) => json!({
|
||||
@@ -45,12 +48,14 @@ pub async fn bridge_runtime(
|
||||
"bridge": "hermes_runtime_result",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(),
|
||||
"result": result,
|
||||
}),
|
||||
Err(error) => {
|
||||
let failure = build_failure_response(error);
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
stamp_ai_bridge_headers(),
|
||||
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
|
||||
));
|
||||
}
|
||||
@@ -64,6 +69,7 @@ pub async fn bridge_runtime(
|
||||
"bridge": "hermes_runtime_plan",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(),
|
||||
"plan": success.plan,
|
||||
})
|
||||
}
|
||||
@@ -71,11 +77,140 @@ pub async fn bridge_runtime(
|
||||
let failure = build_failure_response(error);
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
stamp_ai_bridge_headers(),
|
||||
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
|
||||
}
|
||||
|
||||
fn ai_bridge_contract() -> Value {
|
||||
json!({
|
||||
"schema": "mnote.ai_bridge.v1",
|
||||
"owner": "mnote-web",
|
||||
"bridge": "hermes",
|
||||
"sessionOwner": "rust-web-hermes",
|
||||
"toolEventOwner": "rust-web-hermes",
|
||||
"clientActionOwner": "rust-web-hermes"
|
||||
})
|
||||
}
|
||||
|
||||
fn stamp_ai_bridge_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_AI_BRIDGE_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("rust-web-hermes"));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_bridge_route_returns_hermes_owner_contract() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/bridge")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"kind": "tool",
|
||||
"context": {
|
||||
"deploymentId": null,
|
||||
"projectId": null,
|
||||
"workspaceId": "ws_demo",
|
||||
"requestId": "req_1",
|
||||
"traceId": "trace_1",
|
||||
"actor": {
|
||||
"actorType": "user",
|
||||
"actorId": "user_1",
|
||||
"sessionId": null
|
||||
},
|
||||
"source": {
|
||||
"channel": "rust-web",
|
||||
"client": "mnote-web"
|
||||
},
|
||||
"tenantId": null,
|
||||
"authToken": null,
|
||||
"idempotencyKey": null,
|
||||
"validateOnly": false,
|
||||
"dryRun": false
|
||||
},
|
||||
"tool": {
|
||||
"tool": "docs_search",
|
||||
"kind": "query",
|
||||
"mode": "plan",
|
||||
"argsJson": {"query": "Rust Web"},
|
||||
"target": null,
|
||||
"reason": "owner gate",
|
||||
"refs": []
|
||||
},
|
||||
"data": null
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-ai-bridge-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-web-hermes")
|
||||
);
|
||||
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["contract"]["schema"], "mnote.ai_bridge.v1");
|
||||
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
||||
subtree_query,
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
};
|
||||
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::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -188,8 +188,8 @@ pub async fn graph(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
@@ -199,6 +199,9 @@ mod tests {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
@@ -386,13 +389,11 @@ 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]
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::ssr::pages::mindmap::MindmapPage;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||
|
||||
pub async fn mindmap_object_shell(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path((doc_id, mindmap_id)): Path<(String, String)>,
|
||||
) -> Result<Response, WebError> {
|
||||
let contract = json!({
|
||||
"schema": "mnote.mindmap_shell.v1",
|
||||
"owner": "mnote-web",
|
||||
"shell": "mindmap",
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"projection": {
|
||||
"schema": "mnote.mindmap_projection.v1",
|
||||
"source": "rust-web-object-shell"
|
||||
},
|
||||
"island": {
|
||||
"kind": "react_mindmap_runtime",
|
||||
"mountId": "mnote-mindmap-island",
|
||||
"legacyCompat": "next-app-router"
|
||||
},
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id
|
||||
});
|
||||
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<MindmapPage
|
||||
document_id={doc_id.clone()}
|
||||
mindmap_id={mindmap_id.clone()}
|
||||
/>
|
||||
});
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>思维导图</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
|
||||
{}
|
||||
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&doc_id),
|
||||
escape_html(&mindmap_id),
|
||||
body_content,
|
||||
escape_script_json(&contract_json),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "mindmap");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static(shell));
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn escape_script_json(value: &str) -> String {
|
||||
value.replace("</script", "<\\/script")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mindmap_shell_returns_rust_object_shell_contract() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/mindmap/doc_1/mind_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-shell")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mindmap")
|
||||
);
|
||||
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("mnote.mindmap_shell.v1"));
|
||||
assert!(html.contains("data-react-island=\"mindmap_runtime\""));
|
||||
assert!(html.contains("rust-web-object-shell"));
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,24 @@ mod command_support;
|
||||
mod compat;
|
||||
mod documents;
|
||||
mod editor;
|
||||
mod gateway;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
mod mindmap_shell;
|
||||
mod query_support;
|
||||
mod search;
|
||||
mod session;
|
||||
mod snapshot_support;
|
||||
mod sse;
|
||||
mod stream_support;
|
||||
mod tree;
|
||||
mod web_shell;
|
||||
mod ws;
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let hermes_base_path = state.config().hermes_base_path.clone();
|
||||
@@ -24,6 +29,26 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
let mut router = Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/", get(gateway::root_entry))
|
||||
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
||||
.route("/search", get(search::shell))
|
||||
.route(
|
||||
"/mindmap/{doc_id}/{mindmap_id}",
|
||||
get(mindmap_shell::mindmap_object_shell),
|
||||
)
|
||||
.route(
|
||||
"/documents/{document_id}",
|
||||
get(web_shell::document_page_shell),
|
||||
)
|
||||
.route(
|
||||
"/api/page-aggregate/{document_id}",
|
||||
get(web_shell::page_aggregate),
|
||||
)
|
||||
.route("/api/search/documents", post(search::documents))
|
||||
.route("/api/gateway/health", get(gateway::gateway_health))
|
||||
.route("/api/runtime/config", get(session::runtime_config))
|
||||
.route("/api/auth/session", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/save", post(documents::save))
|
||||
@@ -52,6 +77,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/bridge/workspace", get(bridge::workspace))
|
||||
.route("/api/bridge/request", get(bridge::request))
|
||||
.route("/api/bridge/trace", get(bridge::trace))
|
||||
.route("/api/tree/events", get(sse::tree_events))
|
||||
.route("/api/stream/events", get(sse::events))
|
||||
.route("/api/realtime/ws", get(ws::socket))
|
||||
.nest(
|
||||
@@ -65,7 +91,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/ai-agent/run", post(compat::next_ai_agent_run))
|
||||
.route("/sidebar", get(compat::next_sidebar)),
|
||||
);
|
||||
)
|
||||
.fallback(gateway::legacy_next_proxy);
|
||||
|
||||
if enable_debug_shell_routes {
|
||||
router = router
|
||||
|
||||
@@ -3,13 +3,13 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::execute_convex_query_plan;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
|
||||
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, execute_runtime_input,
|
||||
execute_runtime_query,
|
||||
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
|
||||
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
|
||||
RuntimeSourceWire,
|
||||
};
|
||||
use core_protocol::{GetPageMeta, QueryEnvelope};
|
||||
use serde_json::Value;
|
||||
use storage_convex_bridge::{BridgeContext, build_query_request};
|
||||
use storage_convex_bridge::{build_query_request, BridgeContext};
|
||||
|
||||
pub fn resolve_effective_workspace_id(
|
||||
context: &RequestContext,
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||
use crate::ssr::pages::search::SearchPage;
|
||||
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};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_QUERY_NAME: &str = "x-query-name";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDocumentsRequest {
|
||||
pub workspace_id: Option<String>,
|
||||
pub query: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub filters: Option<SearchDocumentsFilters>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDocumentsFilters {
|
||||
pub title_only: Option<bool>,
|
||||
pub exact: Option<bool>,
|
||||
pub include_ocr: Option<bool>,
|
||||
pub only_current_page: Option<bool>,
|
||||
pub time_range: Option<String>,
|
||||
pub time_field: Option<String>,
|
||||
pub custom_range: Option<SearchCustomRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchCustomRange {
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchShellQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn shell(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<SearchShellQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let workspace_id = query
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("default");
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, workspace_id, None)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
|
||||
let contract = json!({
|
||||
"schema": "mnote.search_shell.v1",
|
||||
"owner": "mnote-web",
|
||||
"shell": "search",
|
||||
"workspaceId": workspace_id,
|
||||
"query": search_query,
|
||||
"initialResults": {
|
||||
"queryName": "search.documents",
|
||||
"results": []
|
||||
},
|
||||
"island": {
|
||||
"kind": "react_search_palette",
|
||||
"mountId": "mnote-search-island",
|
||||
"runtime": "SearchPaletteHost"
|
||||
},
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id
|
||||
});
|
||||
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<SearchPage
|
||||
workspace_id={workspace_id.to_string()}
|
||||
search_query={search_query.to_string()}
|
||||
sidebar_tree_html={sidebar_tree_html}
|
||||
/>
|
||||
});
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>搜索</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="search" data-search-shell-owner="rust-web">
|
||||
{}
|
||||
<script id="__MNOTE_SEARCH_SHELL__" type="application/json">{}</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
body_content,
|
||||
escape_script_json(&contract_json),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
stamp_search_headers(response.headers_mut());
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-shell") {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(name, HeaderValue::from_static("search"));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn documents(
|
||||
State(_state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<SearchDocumentsRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let filters = body.filters.unwrap_or_default();
|
||||
let normalized_query = body.query.unwrap_or_default().trim().to_string();
|
||||
let page_id = if filters.only_current_page.unwrap_or(false) {
|
||||
body.document_id.filter(|value| !value.trim().is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = if normalized_query.is_empty() {
|
||||
json!({
|
||||
"enqueueAssetIds": [],
|
||||
"results": [],
|
||||
})
|
||||
} else {
|
||||
execute_runtime_query_against_data(
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "search.documents".into(),
|
||||
payload: json!({
|
||||
"query": normalized_query,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"pageId": page_id,
|
||||
"limit": body.limit.unwrap_or(30),
|
||||
"titleOnly": filters.title_only.unwrap_or(false),
|
||||
"exact": filters.exact.unwrap_or(false),
|
||||
"includeOcr": filters.include_ocr.unwrap_or(false),
|
||||
"timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
|
||||
"timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
|
||||
"customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
|
||||
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
|
||||
}),
|
||||
},
|
||||
json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "doc_1",
|
||||
"workspaceId": effective_workspace_id,
|
||||
"title": "Rust Web 搜索结果",
|
||||
"rawText": "mnote-web search documents transport",
|
||||
"createdAt": "2026-04-28T00:00:00Z",
|
||||
"updatedAt": "2026-04-28T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"mindmaps": [],
|
||||
"tables": [],
|
||||
"tableRows": [],
|
||||
"assets": []
|
||||
}),
|
||||
)?
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"recent": [],
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
"queryName": "search.documents",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
},
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
fn escape_script_json(value: &str) -> String {
|
||||
value.replace("</script", "<\\/script")
|
||||
}
|
||||
|
||||
fn stamp_search_headers(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_QUERY_NAME.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("search.documents"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_shell_returns_server_first_island_contract() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/search?workspaceId=ws_demo&q=Rust")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-shell")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("search")
|
||||
);
|
||||
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("data-mnote-shell=\"search\""));
|
||||
assert!(html.contains("mnote.search_shell.v1"));
|
||||
assert!(html.contains("react_search_palette"));
|
||||
assert!(html.contains("search.documents"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_documents_route_is_owned_by_mnote_web() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/search/documents")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"query": "Rust Web",
|
||||
"filters": {
|
||||
"titleOnly": false,
|
||||
"exact": false,
|
||||
"includeOcr": false,
|
||||
"onlyCurrentPage": false,
|
||||
"timeRange": "any",
|
||||
"timeField": "updated"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-query-name")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("search.documents")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["meta"]["owner"], "mnote-web");
|
||||
assert_eq!(payload["meta"]["queryName"], "search.documents");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeConfigResponse {
|
||||
pub ok: bool,
|
||||
pub owner: &'static str,
|
||||
pub public_entry: String,
|
||||
pub tree_renderer_family: &'static str,
|
||||
pub document_editor_host: &'static str,
|
||||
pub legacy_next_compat_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionResponse {
|
||||
pub ok: bool,
|
||||
pub owner: &'static str,
|
||||
pub user_id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub actor_type: String,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
pub async fn runtime_config(State(state): State<AppState>) -> Response {
|
||||
owner_json(Json(RuntimeConfigResponse {
|
||||
ok: true,
|
||||
owner: "mnote-web",
|
||||
public_entry: state.config().public_bind_addr.clone(),
|
||||
tree_renderer_family: "rust_family",
|
||||
document_editor_host: "leptos_tiptap_island",
|
||||
legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn session(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Response {
|
||||
owner_json(Json(SessionResponse {
|
||||
ok: true,
|
||||
owner: "mnote-web",
|
||||
user_id: state.config().dev_user_id.clone(),
|
||||
email: state.config().dev_user_email.clone(),
|
||||
name: state.config().dev_user_name.clone(),
|
||||
actor_type: context.auth.actor_type,
|
||||
request_id: context.trace.request_id,
|
||||
trace_id: context.trace.trace_id,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn refresh_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Response {
|
||||
let mut response = owner_json(Json(SessionResponse {
|
||||
ok: true,
|
||||
owner: "mnote-web",
|
||||
user_id: state.config().dev_user_id.clone(),
|
||||
email: state.config().dev_user_email.clone(),
|
||||
name: state.config().dev_user_name.clone(),
|
||||
actor_type: context.auth.actor_type,
|
||||
request_id: context.trace.request_id,
|
||||
trace_id: context.trace.trace_id,
|
||||
}));
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response
|
||||
}
|
||||
|
||||
fn owner_json<T>(payload: Json<T>) -> Response
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let mut response = payload.into_response();
|
||||
stamp_owner_header(response.headers_mut());
|
||||
response
|
||||
}
|
||||
|
||||
fn stamp_owner_header(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_config_is_owned_by_mnote_web_and_hides_internal_urls() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/runtime/config")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["treeRendererFamily"], "rust_family");
|
||||
assert!(payload.get("legacyNextBaseUrl").is_none());
|
||||
assert!(payload.get("convexAdminKey").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_handoff_returns_dev_identity_without_internal_secret() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["userId"], "dev-user");
|
||||
assert!(payload.get("convexAdminKey").is_none());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionSnapshotSpec<'a> {
|
||||
|
||||
@@ -2,10 +2,12 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, load_stream_overview,
|
||||
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
|
||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
|
||||
StreamSnapshotQuery,
|
||||
};
|
||||
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::Value;
|
||||
@@ -117,6 +119,28 @@ pub async fn events(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn tree_events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<StreamSnapshotQuery>,
|
||||
) -> Result<
|
||||
(
|
||||
HeaderMap,
|
||||
Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>,
|
||||
),
|
||||
WebError,
|
||||
> {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-tree-stream-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("rust-web"));
|
||||
}
|
||||
let sse = events(State(state), Extension(context), Query(query)).await?;
|
||||
Ok((headers, sse))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StreamPollState {
|
||||
app_state: AppState,
|
||||
@@ -137,8 +161,8 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -147,6 +171,9 @@ mod tests {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
@@ -183,4 +210,39 @@ mod tests {
|
||||
assert!(text.contains("\"projection\":\"sidebar_tree\""));
|
||||
assert!(text.contains("\"workspaceId\":\"ws_demo\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_realtime_route_returns_rust_web_owned_snapshot_event() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=0")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-tree-stream-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-web")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||
assert!(text.contains("\"kind\":\"snapshot\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
||||
subtree_query,
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
|
||||
"page.body.save",
|
||||
@@ -588,8 +588,8 @@ pub async fn load_stream_snapshot(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_change,
|
||||
resolve_stream_cursor, resolve_stream_scope,
|
||||
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind,
|
||||
StreamSnapshotQuery, StreamSnapshotScope,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -5,34 +5,37 @@ use crate::routes::command_support::{
|
||||
build_tree_target, ensure_non_empty, ensure_sort_order,
|
||||
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::tree_shell::filetree_renderer::{
|
||||
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
|
||||
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
|
||||
};
|
||||
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
||||
use crate::tree_shell::page_renderer::{
|
||||
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
|
||||
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
|
||||
};
|
||||
use crate::tree_shell::picker_renderer::{
|
||||
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
|
||||
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
|
||||
};
|
||||
use crate::tree_shell::renderer_input::{
|
||||
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
||||
TreeShellRendererInput,
|
||||
};
|
||||
use crate::tree_shell::runtime_api::{
|
||||
TreeShellRuntimeRequest, TreeShellRuntimeResult,
|
||||
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
|
||||
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
|
||||
TreeShellRuntimeResult,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -90,6 +93,10 @@ pub enum TreeCommandRequest {
|
||||
parent_id: Option<String>,
|
||||
sort_order: i64,
|
||||
},
|
||||
Purge {
|
||||
workspace_id: Option<String>,
|
||||
document_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
@@ -201,7 +208,7 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
|
||||
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
|
||||
projection
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
@@ -4467,9 +4474,98 @@ fn create_command_wire(
|
||||
validate_only: false,
|
||||
})
|
||||
}
|
||||
TreeCommandRequest::Purge {
|
||||
workspace_id: _,
|
||||
document_id,
|
||||
} => {
|
||||
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
||||
Ok(RuntimeCommandEnvelopeWire {
|
||||
name: "tree.node.purge".into(),
|
||||
command_id: format!("tree_purge_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: bridge_runtime::RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: bridge_runtime::RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(build_tree_target(
|
||||
workspace_id,
|
||||
Some(document_id.as_str()),
|
||||
None,
|
||||
)),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("tree-shell purge".into()),
|
||||
refs: vec!["mnote-web-tree".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_tree_create_workspace_id(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
requested_workspace_id: Option<&str>,
|
||||
parent_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
if let Some(workspace_id) =
|
||||
resolve_effective_workspace_id(context, requested_workspace_id, false)?
|
||||
{
|
||||
return Ok(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?;
|
||||
if let Some(workspace_id) = parent_meta
|
||||
.get("workspace_id")
|
||||
.or_else(|| parent_meta.get("workspaceId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(workspace_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let bootstrap = execute_convex_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"workspaces:ensureDefaultWorkspace",
|
||||
json!({
|
||||
"fallbackName": context.auth.actor_id,
|
||||
"workspaceIdIfCreate": generate_tree_document_id(),
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
"tree_command_workspace_bootstrap",
|
||||
)
|
||||
.await?;
|
||||
bootstrap
|
||||
.get("activeWorkspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_gateway_code(
|
||||
"workspace_bootstrap_bad_response",
|
||||
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_workspace_bootstrap")
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn tree_command(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -4504,6 +4600,10 @@ pub async fn tree_command(
|
||||
parent_id: raw_request.parent_id,
|
||||
sort_order: raw_request.sort_order.unwrap_or(-1),
|
||||
},
|
||||
"purge" => TreeCommandRequest::Purge {
|
||||
workspace_id: raw_request.workspace_id,
|
||||
document_id: raw_request.document_id.unwrap_or_default(),
|
||||
},
|
||||
other => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
@@ -4517,6 +4617,7 @@ pub async fn tree_command(
|
||||
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
|
||||
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
|
||||
TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(),
|
||||
TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(),
|
||||
};
|
||||
let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) =
|
||||
match &request {
|
||||
@@ -4553,10 +4654,27 @@ pub async fn tree_command(
|
||||
None,
|
||||
Some(*sort_order),
|
||||
),
|
||||
TreeCommandRequest::Purge { document_id, .. } => (
|
||||
"purge",
|
||||
document_id.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, requested_workspace_id, true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let effective_workspace_id = match &request {
|
||||
TreeCommandRequest::Create { parent_id, .. } => {
|
||||
resolve_tree_create_workspace_id(
|
||||
&state,
|
||||
&context,
|
||||
requested_workspace_id,
|
||||
parent_id.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
_ => resolve_effective_workspace_id(&context, requested_workspace_id, true)?
|
||||
.expect("workspace_required 已确保存在"),
|
||||
};
|
||||
let command_wire = create_command_wire(&context, &effective_workspace_id, request)?;
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
@@ -4607,8 +4725,8 @@ pub async fn reduce_tree_shell_runtime(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TreeCommandRequest, create_command_wire};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use super::{create_command_wire, TreeCommandRequest};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
@@ -4621,6 +4739,9 @@ mod tests {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
@@ -4628,7 +4749,7 @@ mod tests {
|
||||
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()),
|
||||
mutation_fixtures_json: Some(r#"{"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"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.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(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -4795,9 +4916,8 @@ 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\"]"
|
||||
));
|
||||
@@ -4825,9 +4945,8 @@ 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]
|
||||
@@ -4991,6 +5110,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_create_uses_default_workspace_when_workspace_missing() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"action":"create","title":"新页面"}"#))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["action"], Value::String("create".into()));
|
||||
assert_eq!(
|
||||
payload["result"]["workspaceId"],
|
||||
Value::String("ws_demo".into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_new".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_purge_uses_tree_command_protocol() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"action":"purge","workspaceId":"ws_demo","documentId":"page_child"}"#,
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["action"], Value::String("purge".into()));
|
||||
assert_eq!(
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_child".into())
|
||||
);
|
||||
assert_eq!(payload["result"]["execution"]["deletedCount"], Value::from(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_rejects_negative_sort_order() {
|
||||
let response = app()
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::page_aggregate::PageAggregate;
|
||||
use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
||||
};
|
||||
use crate::routes::tree::collect_page_tree_render_rows;
|
||||
use crate::tree_shell::page_renderer::{
|
||||
render_initial_page_tree_html, PageTreeInitialRenderInput,
|
||||
};
|
||||
use crate::workspace_shell::{build_workspace_shell_projection, WorkspaceShellProjection};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use crate::ssr::pages::document::DocumentPage;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentShellQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn document_page_shell(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(document_id): Path<String>,
|
||||
Query(query): Query<DocumentShellQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
&state,
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let title = aggregate.head_title();
|
||||
let workspace_id = query
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("default");
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<DocumentPage title={title.to_string()} sidebar_tree_html={sidebar_tree_html} />
|
||||
});
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
|
||||
{}
|
||||
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&document_id),
|
||||
body_content,
|
||||
escape_script_json(&snapshot_json),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "document");
|
||||
stamp_recent_page_cookie(response.headers_mut(), &document_id);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn page_aggregate(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(document_id): Path<String>,
|
||||
Query(query): Query<DocumentShellQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
&state,
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let mut response = (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"schema": "mnote.page_aggregate.v1",
|
||||
"result": aggregate,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "page-aggregate");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn build_page_aggregate_snapshot(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
) -> Result<PageAggregate, WebError> {
|
||||
let meta = load_document_meta_result(
|
||||
state,
|
||||
context,
|
||||
DocumentMetaQuery {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: workspace_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let content = load_document_content_result(
|
||||
state,
|
||||
context,
|
||||
DocumentContentQuery {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: workspace_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let conflict_detection_key = content
|
||||
.get("conflictDetectionKey")
|
||||
.or_else(|| content.get("conflict_detection_key"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let page_subtree = content
|
||||
.get("pageSubtree")
|
||||
.or_else(|| content.get("page_subtree"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let todo_total = meta
|
||||
.get("todo_total")
|
||||
.or_else(|| meta.get("todo_total_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let todo_done = meta
|
||||
.get("todo_done")
|
||||
.or_else(|| meta.get("todo_done_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(PageAggregate::builder()
|
||||
// identity
|
||||
.document_id(meta.get("id").and_then(Value::as_str).unwrap_or(document_id))
|
||||
.workspace_id(meta.get("workspace_id").and_then(Value::as_str).unwrap_or("default"))
|
||||
// head
|
||||
.title(meta.get("title").and_then(Value::as_str).unwrap_or("无标题"))
|
||||
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
|
||||
.read_only(meta.get("can_edit").and_then(Value::as_bool).map(|can_edit| !can_edit).unwrap_or(false))
|
||||
.disable_download(meta.get("disable_download").and_then(Value::as_bool).unwrap_or(false))
|
||||
.disable_copy(meta.get("disable_copy").and_then(Value::as_bool).unwrap_or(false))
|
||||
// layout
|
||||
.wide_layout(meta.get("wide_layout").and_then(Value::as_bool).unwrap_or(false))
|
||||
.small_text(meta.get("use_small_text").and_then(Value::as_bool).unwrap_or(false))
|
||||
.show_heading_numbers(meta.get("show_heading_numbers").and_then(Value::as_bool).unwrap_or(true))
|
||||
.show_toc(meta.get("show_toc").and_then(Value::as_bool).unwrap_or(false))
|
||||
.show_structure(meta.get("show_structure").and_then(Value::as_bool).unwrap_or(false))
|
||||
.protect_editing(meta.get("protect_editing").and_then(Value::as_bool).unwrap_or(false))
|
||||
.show_word_count(meta.get("show_word_count").and_then(Value::as_bool).unwrap_or(true))
|
||||
.collapse_backlinks(meta.get("collapse_backlinks").and_then(Value::as_bool).unwrap_or(false))
|
||||
.page_font(meta.get("page_font").and_then(Value::as_str).unwrap_or("default"))
|
||||
.layout_density(meta.get("layout_density").and_then(Value::as_str).unwrap_or("normal"))
|
||||
.hide_child_pages(meta.get("hide_child_pages").and_then(Value::as_bool).unwrap_or(false))
|
||||
.show_block_ref_count(meta.get("show_block_ref_count").and_then(Value::as_bool).unwrap_or(false))
|
||||
.embed_default_block_id(meta.get("embed_default_block_id").cloned().unwrap_or(Value::Null))
|
||||
// body
|
||||
.content(content.get("content").cloned().unwrap_or(Value::Null))
|
||||
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
|
||||
.conflict_detection_key(conflict_detection_key)
|
||||
// tree
|
||||
.page_subtree(page_subtree)
|
||||
// stats
|
||||
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
|
||||
.character_count(meta.get("character_count").and_then(Value::as_u64).unwrap_or(0))
|
||||
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
|
||||
.todo_total(todo_total)
|
||||
.todo_done(todo_done)
|
||||
.build())
|
||||
}
|
||||
|
||||
|
||||
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
||||
let value = document_id
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
||||
headers.append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static(shell));
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn escape_script_json(value: &str) -> String {
|
||||
value.replace("</script", "<\\/script")
|
||||
}
|
||||
|
||||
pub(crate) async fn load_workspace_shell_projection(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
active_document_id: Option<&str>,
|
||||
default_workspace_name: &str,
|
||||
) -> WorkspaceShellProjection {
|
||||
let spec = ProjectionSnapshotSpec {
|
||||
workspace_id,
|
||||
root_node_id: None,
|
||||
depth: Some(99),
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
query: None,
|
||||
max_results: None,
|
||||
};
|
||||
let dataset = load_projection_snapshot(config, context, &spec)
|
||||
.await
|
||||
.map(|snapshot| snapshot.dataset)
|
||||
.unwrap_or_else(|_| {
|
||||
let documents = active_document_id
|
||||
.map(|document_id| {
|
||||
json!([{
|
||||
"id": document_id,
|
||||
"workspace_id": workspace_id,
|
||||
"title": "个人",
|
||||
"parent_id": null,
|
||||
"sort_order": 0,
|
||||
"is_starred": false
|
||||
}])
|
||||
})
|
||||
.unwrap_or_else(|| json!([]));
|
||||
json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"active_page_id": active_document_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
||||
"documents": documents
|
||||
})
|
||||
});
|
||||
|
||||
build_workspace_shell_projection(
|
||||
&dataset,
|
||||
workspace_id,
|
||||
active_document_id,
|
||||
default_workspace_name,
|
||||
)
|
||||
}
|
||||
|
||||
/// 加载侧栏页面树 HTML(SSR)
|
||||
///
|
||||
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
||||
/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。
|
||||
/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。
|
||||
pub(crate) async fn load_sidebar_tree_html(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
active_document_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let spec = ProjectionSnapshotSpec {
|
||||
workspace_id,
|
||||
root_node_id: None,
|
||||
depth: Some(99),
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
query: None,
|
||||
max_results: None,
|
||||
};
|
||||
let result = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => Some(snapshot.projection),
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
// Dev 模式降级:使用内建示例页面树数据集
|
||||
let dev_dataset = serde_json::json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"documents": [
|
||||
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
|
||||
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
|
||||
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 },
|
||||
],
|
||||
"trashed_documents": [],
|
||||
"media_assets": [],
|
||||
"trashed_media_assets": [],
|
||||
"mindmap_assets": [],
|
||||
"trashed_mindmap_assets": [],
|
||||
"table_assets": [],
|
||||
"trashed_table_assets": [],
|
||||
"mindmap_docs": [],
|
||||
"mindmap_asset_children": {}
|
||||
});
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
result.map(|projection| {
|
||||
let rows = collect_page_tree_render_rows(&projection);
|
||||
render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
rows,
|
||||
active_node_id: active_document_id.map(ToOwned::to_owned),
|
||||
focused_node_id: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"updated_at": "2026-04-18T09:30:00Z",
|
||||
"can_edit": true,
|
||||
"word_count": 42,
|
||||
"character_count": 128,
|
||||
"block_count": 3
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_returns_page_aggregate_snapshot() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/documents/doc_1?workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-shell")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("document")
|
||||
);
|
||||
assert!(response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.any(|value| value
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
.contains("mnote_recent_page_id=doc_1")));
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar\""));
|
||||
assert!(html.contains("data-testid=\"wolai-topbar\""));
|
||||
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
|
||||
assert!(html.contains("星标置顶"));
|
||||
assert!(html.contains("我的页面"));
|
||||
assert!(html.contains("垃圾箱"));
|
||||
assert!(html.contains("模板中心"));
|
||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_returns_snapshot_contract() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{StreamSnapshotQuery, load_stream_snapshot};
|
||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::Response;
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn socket(
|
||||
ws: WebSocketUpgrade,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! SSR (Server-Side Rendering) 模块
|
||||
//!
|
||||
//! 提供 Leptos 0.8 的 SSR 渲染基础设施。
|
||||
//! 使用 `RenderHtml::to_html_with_buf` 将 Leptos view 渲染为 HTML 字符串。
|
||||
|
||||
pub mod pages;
|
||||
pub mod styles;
|
||||
|
||||
pub use styles::MNOTE_CSS;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::tachys::view::Position;
|
||||
|
||||
/// 将任何实现 `RenderHtml` 的视图渲染为 HTML 字符串 (SSR)
|
||||
///
|
||||
/// 使用 Leptos 0.8 的 `RenderHtml::to_html_with_buf` 方法,
|
||||
/// 通过 `Position::default()` (即 FirstChild) 初始化位置状态。
|
||||
pub fn render_view(view: impl RenderHtml) -> String {
|
||||
let mut buf = String::new();
|
||||
let mut position = Position::default();
|
||||
RenderHtml::to_html_with_buf(
|
||||
view,
|
||||
&mut buf,
|
||||
&mut position,
|
||||
true, // escape — 对 HTML 特殊字符进行转义
|
||||
false, // mark_branches — 不标记分支注释
|
||||
vec![], // extra_attrs — 无需额外属性
|
||||
);
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use leptos::view;
|
||||
|
||||
#[test]
|
||||
fn render_view_produces_non_empty_html() {
|
||||
let html = render_view(view! { <h1>"Hello SSR"</h1> });
|
||||
assert!(!html.is_empty());
|
||||
assert!(html.contains("Hello SSR"));
|
||||
assert!(html.contains("<h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_view_escapes_html_special_chars() {
|
||||
let html = render_view(view! { <p>"<script>alert('xss')</script>"</p> });
|
||||
assert!(html.contains("<"));
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_view_renders_nested_elements() {
|
||||
let html = render_view(view! {
|
||||
<main>
|
||||
<header><h1>"Title"</h1></header>
|
||||
<section><p>"Content"</p></section>
|
||||
</main>
|
||||
});
|
||||
assert!(html.contains("<main>"));
|
||||
assert!(html.contains("<header>"));
|
||||
assert!(html.contains("<h1>Title</h1>"));
|
||||
assert!(html.contains("<section>"));
|
||||
assert!(html.contains("<p>Content</p>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! MNOTE 登录页面组件
|
||||
|
||||
use leptos::prelude::*;
|
||||
use super::layout::PageLayout;
|
||||
|
||||
/// MNOTE 登录页面
|
||||
///
|
||||
/// 当 legacy compat 关闭时由 `auth_entry` 路由使用。
|
||||
#[component]
|
||||
pub fn AuthPage() -> impl IntoView {
|
||||
view! {
|
||||
<PageLayout current_nav="home">
|
||||
<section class="mnote-auth">
|
||||
<h1>MNOTE 登录</h1>
|
||||
<p>
|
||||
"此页面由 mnote-web gateway 提供。"
|
||||
</p>
|
||||
</section>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! MNOTE 文档页面组件 (SSR)
|
||||
//!
|
||||
//! 提供文档编辑器的服务器端渲染壳。
|
||||
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
|
||||
|
||||
use leptos::prelude::*;
|
||||
use crate::ssr::pages::layout::PageLayout;
|
||||
|
||||
/// MNOTE 文档页面
|
||||
///
|
||||
/// 渲染文档编辑器的 SSR 壳结构:
|
||||
/// - `<PageLayout current_nav="documents">`
|
||||
/// - 标题区域
|
||||
/// - 页面聚合快照标记
|
||||
/// - 编辑器占位区
|
||||
#[component]
|
||||
pub fn DocumentPage(
|
||||
/// 文档标题
|
||||
title: String,
|
||||
/// 侧栏页面树 HTML(可选)
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
/// 工作区名称(可选)
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
|
||||
<main class="document-shell" data-editor-host="leptos_tiptap_island">
|
||||
<header class="document-shell-header">
|
||||
<h1>{title}</h1>
|
||||
</header>
|
||||
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
|
||||
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island"></section>
|
||||
</main>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! MNOTE 首页组件(Wolai 风格)
|
||||
|
||||
use leptos::prelude::*;
|
||||
use super::layout::PageLayout;
|
||||
|
||||
/// MNOTE 首页。
|
||||
#[component]
|
||||
pub fn HomePage(
|
||||
/// 侧栏页面树 HTML(可选)
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
/// 工作区名称(可选)
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
/// workspace shell 侧栏 sections HTML(可选)
|
||||
#[prop(optional)]
|
||||
workspace_sidebar_html: Option<String>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}>
|
||||
<div class="mnote-home">
|
||||
<div class="mnote-home-icon" aria-hidden="true">"⌂"</div>
|
||||
<h1>"个人"</h1>
|
||||
<div class="mnote-home-links">
|
||||
<a href="/documents/backup"><span>"◰"</span>"正版软件备份"</a>
|
||||
<a href="/documents/growth"><span>"◜"</span>"个人发展"</a>
|
||||
<a href="/documents/notes"><span>"▦"</span>"杂记"</a>
|
||||
<a href="/documents/passwords"><span>"▣"</span>"密码"</a>
|
||||
<a href="/documents/wolai-guide"><span>"▰"</span>"Wolai 教程"</a>
|
||||
<a href="/documents/health"><span>"⌂"</span>"个人健康"</a>
|
||||
<a href="/documents/software"><span>"▤"</span>"软件开发"</a>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
const SIDEBAR_TREE_JS: &str = r##"
|
||||
<script>
|
||||
(function(){
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
if (!tree) return;
|
||||
|
||||
// 高亮当前页
|
||||
var currentPath = window.location.pathname;
|
||||
var match = currentPath.match(/^\/documents\/([^\/]+)/);
|
||||
if (match) {
|
||||
var activeId = match[1];
|
||||
var links = tree.querySelectorAll('[data-rust-action="open"]');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
if (links[i].getAttribute('data-node-id') === activeId) {
|
||||
links[i].closest('.tree-row').setAttribute('data-active', 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 展开/折叠
|
||||
tree.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('[data-rust-action]');
|
||||
if (!btn) return;
|
||||
var nodeId = btn.getAttribute('data-node-id');
|
||||
var action = btn.getAttribute('data-rust-action');
|
||||
|
||||
if (action === 'toggle') {
|
||||
var row = btn.closest('.tree-row');
|
||||
if (!row) return;
|
||||
var li = row.parentElement;
|
||||
var children = li.querySelector(':scope > .tree-children');
|
||||
if (children) {
|
||||
children.classList.toggle('tree-children--collapsed');
|
||||
var isCollapsed = children.classList.contains('tree-children--collapsed');
|
||||
row.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
||||
btn.textContent = isCollapsed ? '▸' : '▾';
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (action === 'open') {
|
||||
window.location.href = '/documents/' + encodeURIComponent(nodeId);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
"##;
|
||||
|
||||
/// MNOTE Wolai 风格页面布局
|
||||
///
|
||||
/// 包含左侧栏 + 内容区的双栏布局。
|
||||
/// 侧栏显示品牌、导航链接和可选的页面树。
|
||||
///
|
||||
/// # 用法
|
||||
///
|
||||
/// ```ignore
|
||||
/// view! {
|
||||
/// <PageLayout current_nav="home" sidebar_tree_html={None}>
|
||||
/// <section>...</section>
|
||||
/// </PageLayout>
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn PageLayout(
|
||||
children: Children,
|
||||
current_nav: &'static str,
|
||||
/// 侧栏页面树 HTML(可选),由路由 handler 渲染
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
/// 工作区名称(可选),显示在侧栏顶部
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
|
||||
#[prop(optional)]
|
||||
workspace_sidebar_html: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
|
||||
|
||||
let ws_name = workspace_name
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "开发用户 的空间".to_string());
|
||||
let sidebar_sections_html = workspace_sidebar_html
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
let dataset = serde_json::json!({
|
||||
"workspaces": [{ "id": "default", "name": ws_name.clone() }],
|
||||
"documents": []
|
||||
});
|
||||
let projection = crate::workspace_shell::build_workspace_shell_projection(
|
||||
&dataset,
|
||||
"default",
|
||||
None,
|
||||
&ws_name,
|
||||
);
|
||||
crate::workspace_shell::render_workspace_shell_sidebar_html(
|
||||
&projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
)
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
|
||||
<div class="mnote-sidebar-header wolai-sidebar-header" data-testid="wolai-workspace-identity">
|
||||
<a href="/" class="mnote-sidebar-brand wolai-avatar" aria-label="工作区首页">"L"</a>
|
||||
<span class="sidebar-workspace-name">{ws_name}</span>
|
||||
<span class="wolai-sidebar-chevron" aria-hidden="true">"⌄"</span>
|
||||
</div>
|
||||
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索"><span class="nav-icon">"⌕"</span></a>
|
||||
<a href="/graph" title="关系图"><span class="nav-icon">"⌬"</span></a>
|
||||
<a href="/actions" title="快捷动作"><span class="nav-icon">"↯"</span></a>
|
||||
<a href="/inbox" title="收纳"><span class="nav-icon">"▣"</span></a>
|
||||
<a href="/files" title="文件"><span class="nav-icon">"▱"</span></a>
|
||||
<a href="/more" title="更多"><span class="nav-icon">"…"</span></a>
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
||||
</aside>
|
||||
<div class="mnote-main">
|
||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||
<div class="wolai-topbar-left"><span class="wolai-menu-icon">"☰"</span><span class="wolai-home-icon">"⌂"</span><span>{"个人"}</span></div>
|
||||
<div class="wolai-topbar-actions" aria-label="页面操作">
|
||||
<span title="收藏">"☆"</span>
|
||||
<span title="演示">"▻"</span>
|
||||
<span title="评论">"◴"</span>
|
||||
<span title="关系">"⌬"</span>
|
||||
<span title="成员">"♙+"</span>
|
||||
<span title="历史">"◷"</span>
|
||||
<span title="更多">"…"</span>
|
||||
</div>
|
||||
</header>
|
||||
<article class="mnote-content">
|
||||
{children()}
|
||||
</article>
|
||||
<div class="wolai-floating-actions" aria-label="浮动操作">
|
||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button">"AI"</button>
|
||||
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button">"?"</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! MNOTE 思维导图页面组件 (SSR)
|
||||
//!
|
||||
//! 提供思维导图页面的服务器端渲染壳。
|
||||
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
|
||||
|
||||
use leptos::prelude::*;
|
||||
use super::layout::PageLayout;
|
||||
|
||||
/// MNOTE 思维导图页面
|
||||
///
|
||||
/// 渲染思维导图的 SSR 壳结构:
|
||||
/// - `<main id="mnote-mindmap-shell" data-object-shell="mindmap">`
|
||||
/// - 思维导图标题区域
|
||||
/// - React 运行时挂载占位区
|
||||
#[component]
|
||||
pub fn MindmapPage(
|
||||
/// 文档 ID
|
||||
document_id: String,
|
||||
/// 思维导图 ID
|
||||
mindmap_id: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<PageLayout current_nav="documents">
|
||||
<main
|
||||
id="mnote-mindmap-shell"
|
||||
data-object-shell="mindmap"
|
||||
data-document-id={document_id}
|
||||
data-mindmap-id={mindmap_id}
|
||||
>
|
||||
<header>
|
||||
<h1>{"思维导图"}</h1>
|
||||
</header>
|
||||
<section id="mnote-mindmap-island" data-react-island="mindmap_runtime"></section>
|
||||
</main>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! SSR 页面组件
|
||||
|
||||
pub mod auth;
|
||||
pub mod document;
|
||||
pub mod home;
|
||||
pub mod layout;
|
||||
pub mod mindmap;
|
||||
pub mod search;
|
||||
@@ -0,0 +1,80 @@
|
||||
//! MNOTE 搜索页面组件 (SSR)
|
||||
//!
|
||||
//! 提供搜索页面的服务器端渲染壳。
|
||||
//! 由 `<body>` 外层包装、搜索契约 JSON 嵌入脚本由路由 handler 处理。
|
||||
|
||||
use leptos::prelude::*;
|
||||
use crate::ssr::pages::layout::PageLayout;
|
||||
|
||||
/// MNOTE 搜索页面
|
||||
///
|
||||
/// 渲染搜索页面的 SSR 壳结构:
|
||||
/// - `<main id="mnote-search-shell" data-island-host="react_search_palette">`
|
||||
/// - 搜索标题区域
|
||||
/// - 搜索岛占位区
|
||||
#[component]
|
||||
pub fn SearchPage(
|
||||
/// 工作区 ID
|
||||
#[prop(into)]
|
||||
workspace_id: String,
|
||||
/// 搜索查询
|
||||
#[prop(into)]
|
||||
search_query: String,
|
||||
/// 侧栏页面树 HTML(可选)
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
/// 工作区名称(可选)
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<PageLayout current_nav="search" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
|
||||
<main id="mnote-search-shell" data-island-host="react_search_palette">
|
||||
<header class="search-header">
|
||||
<h1>{"搜索"}</h1>
|
||||
<p class="search-meta">
|
||||
{format!("工作区: {}", workspace_id)}
|
||||
</p>
|
||||
<p class="search-query">
|
||||
{if search_query.is_empty() {
|
||||
"请输入关键词".to_string()
|
||||
} else {
|
||||
format!("查询: {}", search_query)
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
<section id="mnote-search-island" data-react-island="search_palette"></section>
|
||||
</main>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ssr::render_view;
|
||||
|
||||
#[test]
|
||||
fn search_page_renders_correct_structure() {
|
||||
let html = render_view(view! {
|
||||
<SearchPage workspace_id="ws_demo" search_query="Rust" />
|
||||
});
|
||||
assert!(html.contains("mnote-search-shell"));
|
||||
assert!(html.contains("data-island-host=\"react_search_palette\""));
|
||||
assert!(html.contains("mnote-search-island"));
|
||||
assert!(html.contains("data-react-island=\"search_palette\""));
|
||||
assert!(html.contains("搜索"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_page_contains_layout() {
|
||||
let html = render_view(view! {
|
||||
<SearchPage workspace_id="ws_demo" search_query="" />
|
||||
});
|
||||
assert!(html.contains("mnote-shell"));
|
||||
assert!(html.contains("mnote-sidebar"));
|
||||
assert!(html.contains("mnote-sidebar-brand"));
|
||||
assert!(html.contains("mnote-sidebar-nav"));
|
||||
assert!(html.contains("mnote-content"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
//! MNOTE CSS 样式常量
|
||||
//!
|
||||
//! 为所有 Leptos SSR 页面提供 wolai / Notion 风格样式,
|
||||
//! 匹配 3100 (Next.js) 前端的 wolai 设计系统。
|
||||
|
||||
/// MNOTE 完整 CSS 样式字符串
|
||||
///
|
||||
/// Wolai / Notion 风格:白色背景、近黑正文、浅色侧栏、极简边框。
|
||||
/// 覆盖所有页面组件使用的 class 和 id。
|
||||
pub const MNOTE_CSS: &str = r##"
|
||||
/* ===== 基础重置 ===== */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ===== Wolai 设计变量(来自设计稿) ===== */
|
||||
:root {
|
||||
/* 背景色 */
|
||||
--color-basic-50: #F7F7F5;
|
||||
--color-basic-75: #F0F0EE;
|
||||
--color-basic-100: #EBEBEB;
|
||||
--color-basic-200: #E3E3E0;
|
||||
--color-basic-300: #D4D4D1;
|
||||
--color-basic-400: #C4C4C1;
|
||||
--color-basic-500: #A9A9A6;
|
||||
--color-basic-600: #9B9A97;
|
||||
--color-basic-700: #6D6D69;
|
||||
--color-basic-800: #504F4B;
|
||||
--color-basic-900: #37352F;
|
||||
|
||||
/* wolai 语义变量 */
|
||||
--wolai-bg: #FFFFFF;
|
||||
--wolai-bg-sidebar: var(--color-basic-50);
|
||||
--wolai-bg-hover: rgba(55, 53, 47, 0.08);
|
||||
--wolai-bg-active: rgba(55, 53, 47, 0.12);
|
||||
--wolai-text-primary: var(--color-basic-900);
|
||||
--wolai-text-secondary: var(--color-basic-600);
|
||||
--wolai-border: #E9E9E8;
|
||||
--wolai-brand: #22A559;
|
||||
--wolai-accent: #2563eb;
|
||||
--wolai-accent-hover: #1d4ed8;
|
||||
--wolai-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
|
||||
--wolai-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== 基础排版 ===== */
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--wolai-font-sans);
|
||||
color: var(--wolai-text-primary);
|
||||
background: var(--wolai-bg);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--wolai-accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--wolai-accent-hover);
|
||||
}
|
||||
|
||||
/* ===== 滚动条(悬停时显示) ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ===== 页面布局:侧栏 + 内容 ===== */
|
||||
.mnote-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.wolai-workspace-shell {
|
||||
--wolai-bg-sidebar: #F7F7F6;
|
||||
--wolai-bg-selected: #F8E6E7;
|
||||
--wolai-accent-red: #E0525B;
|
||||
}
|
||||
|
||||
.wolai-sidebar {
|
||||
width: 288px;
|
||||
background: var(--wolai-bg-sidebar);
|
||||
}
|
||||
|
||||
.wolai-sidebar-header {
|
||||
height: 52px;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.wolai-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: #D6545D;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-sidebar-header .sidebar-workspace-name {
|
||||
max-width: 190px;
|
||||
color: #202124;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wolai-sidebar-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 12px 18px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a {
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: #4B4B4B;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a .nav-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wolai-sidebar-section {
|
||||
padding: 4px 8px 14px;
|
||||
}
|
||||
|
||||
.wolai-section-title {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #B5B5B2;
|
||||
font-size: 14px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.wolai-section-icon {
|
||||
color: #FFB33F;
|
||||
}
|
||||
|
||||
.wolai-section-caret {
|
||||
color: #B5B5B2;
|
||||
}
|
||||
|
||||
.wolai-section-add {
|
||||
margin-left: auto;
|
||||
font-size: 22px;
|
||||
color: #B5B5B2;
|
||||
}
|
||||
|
||||
.wolai-page-row {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
color: #535353;
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wolai-page-row:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
.wolai-muted-row {
|
||||
color: #747471;
|
||||
}
|
||||
|
||||
.wolai-active-row {
|
||||
background: var(--wolai-bg-selected);
|
||||
color: var(--wolai-accent-red);
|
||||
}
|
||||
|
||||
.wolai-row-icon {
|
||||
width: 22px;
|
||||
color: #111;
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-sidebar-footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: 1px solid var(--wolai-border);
|
||||
background: #FBFBFA;
|
||||
}
|
||||
|
||||
.wolai-footer-entry {
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #6D6D69;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.wolai-footer-entry + .wolai-footer-entry {
|
||||
border-left: 1px solid var(--wolai-border);
|
||||
}
|
||||
|
||||
.wolai-topbar {
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 18px;
|
||||
color: #222;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.wolai-topbar-left,
|
||||
.wolai-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wolai-topbar-actions {
|
||||
color: #222;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.wolai-menu-icon {
|
||||
color: #4A4A4A;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.wolai-home-icon {
|
||||
color: #000;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wolai-floating-actions {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.wolai-floating-button {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 1px solid #ECECF1;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 侧栏导航 */
|
||||
.mnote-sidebar {
|
||||
width: 288px;
|
||||
flex-shrink: 0;
|
||||
background: var(--wolai-bg-sidebar);
|
||||
border-right: 1px solid var(--wolai-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-header {
|
||||
padding: 14px 16px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mnote-sidebar-brand {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--wolai-text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-brand:hover {
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--wolai-radius);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--wolai-text-primary);
|
||||
text-decoration: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a.active {
|
||||
background: var(--wolai-bg-active);
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a .nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 工作区名称 */
|
||||
.sidebar-workspace-name {
|
||||
font-size: 12px;
|
||||
color: var(--wolai-text-secondary);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 侧栏页面树区 */
|
||||
.sidebar-tree-section {
|
||||
border-top: 1px solid var(--wolai-border);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.sidebar-tree-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-tree {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-root {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--wolai-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--wolai-text-primary);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"] {
|
||||
background: var(--wolai-bg-active);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"] .tree-link-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--wolai-text-secondary);
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-spacer {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-link {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
color: var(--wolai-text-primary);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-link-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row:hover .tree-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--wolai-text-secondary);
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-action:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-children {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-children--collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 树行缩进 — 通过嵌套深度自动偏移 */
|
||||
.sidebar-tree .tree-node .tree-children .tree-row {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node .tree-children .tree-children .tree-row {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-row {
|
||||
padding-left: 68px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-children .tree-row {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.mnote-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.mnote-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ===== 首页 ===== */
|
||||
.mnote-home {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 72px 64px;
|
||||
}
|
||||
|
||||
.mnote-home-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 22px;
|
||||
color: #000;
|
||||
font-size: 92px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mnote-home h1 {
|
||||
text-align: center;
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 34px;
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
.mnote-home p {
|
||||
font-size: 16px;
|
||||
color: var(--wolai-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.mnote-home-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mnote-home-links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
border-radius: var(--wolai-radius);
|
||||
font-size: 21px;
|
||||
font-weight: 650;
|
||||
color: var(--wolai-text-primary);
|
||||
text-decoration: none;
|
||||
transition: background-color 80ms ease;
|
||||
}
|
||||
|
||||
.mnote-home-links a:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-home-links a::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.mnote-home-links a span {
|
||||
width: 26px;
|
||||
text-align: center;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ===== 文档壳 ===== */
|
||||
.document-shell {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
padding: 48px 0 0;
|
||||
margin: 0 auto 8px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.document-shell-header h1 {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--wolai-text-primary);
|
||||
word-break: break-word;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
#mnote-editor-island {
|
||||
margin: 0 15px;
|
||||
max-width: 760px;
|
||||
width: calc(100% - 30px);
|
||||
min-height: 300px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
#mnote-editor-island .bn-editor {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== 搜索壳 ===== */
|
||||
#mnote-search-shell {
|
||||
max-width: 800px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 48px 64px;
|
||||
}
|
||||
|
||||
#mnote-search-shell header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#mnote-search-shell h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--wolai-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search-meta,
|
||||
.search-query {
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#mnote-search-island {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ===== 思维导图壳 ===== */
|
||||
#mnote-mindmap-shell {
|
||||
max-width: 1000px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 48px 64px;
|
||||
}
|
||||
|
||||
#mnote-mindmap-shell header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#mnote-mindmap-shell h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
#mnote-mindmap-island {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* ===== 认证页面 ===== */
|
||||
.mnote-auth {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.mnote-auth-card {
|
||||
text-align: center;
|
||||
padding: 48px 32px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.mnote-auth-card h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--wolai-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mnote-auth-card p {
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== ProseMirror 兼容 ===== */
|
||||
.ProseMirror {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.mnote-sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mnote-home {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.mnote-home h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
padding: 24px 0 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.document-shell-header h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
#mnote-editor-island {
|
||||
margin: 0 24px;
|
||||
width: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
#mnote-search-shell,
|
||||
#mnote-mindmap-shell {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
#mnote-search-shell h1,
|
||||
#mnote-mindmap-shell h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mnote-sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--wolai-border);
|
||||
}
|
||||
|
||||
.mnote-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mnote-home {
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.mnote-home h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
.document-shell-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#mnote-editor-island {
|
||||
margin: 0 16px;
|
||||
width: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
"##;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_core_selectors() {
|
||||
// Wolai 设计系统 CSS 变量
|
||||
assert!(MNOTE_CSS.contains("--wolai-text-primary"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-text-secondary"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-border"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-bg-sidebar"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-accent"));
|
||||
|
||||
// 布局结构
|
||||
assert!(MNOTE_CSS.contains(".mnote-shell"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-main"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_shell_ids() {
|
||||
assert!(MNOTE_CSS.contains(".document-shell"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-search-shell"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-mindmap-shell"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-editor-island"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-search-island"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-mindmap-island"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_responsive_breakpoints() {
|
||||
assert!(MNOTE_CSS.contains("@media (max-width: 768px)"));
|
||||
assert!(MNOTE_CSS.contains("@media (max-width: 480px)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_scrollbar_styles() {
|
||||
assert!(MNOTE_CSS.contains("::-webkit-scrollbar"));
|
||||
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_prosemirror_styles() {
|
||||
assert!(MNOTE_CSS.contains(".ProseMirror"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 最多 20000 字符避免过于臃肿
|
||||
assert!(MNOTE_CSS.len() < 20000);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use bridge_runtime::{
|
||||
RuntimeBridgeContextWire, RuntimeCommandArtifactPlan, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan, build_runtime_command_artifact_plan,
|
||||
build_runtime_command_artifact_plan, RuntimeBridgeContextWire, RuntimeCommandArtifactPlan,
|
||||
RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use base64::Engine;
|
||||
|
||||
const HEADER_REQUEST_ID: &str = "x-request-id";
|
||||
const HEADER_TRACE_ID: &str = "x-trace-id";
|
||||
@@ -17,6 +18,7 @@ const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
|
||||
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
|
||||
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
|
||||
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
||||
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
@@ -60,6 +62,10 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
||||
return Ok(authorization.to_string());
|
||||
}
|
||||
|
||||
if let Some(convex_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) {
|
||||
return Ok(format!("Bearer {convex_token}"));
|
||||
}
|
||||
|
||||
if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) {
|
||||
return Ok(format!("Bearer {convex_token}"));
|
||||
}
|
||||
@@ -78,12 +84,23 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
// 说明:
|
||||
// - Next 侧 `setAdminAuth(adminKey)` 走的是纯 admin auth,而不是伪造用户身份。
|
||||
// - Convex 业务函数内部会在缺少真实 token 时自行回退到 DEV_USER_ID。
|
||||
// - 这里若强行附带伪造 identity,会让 getAuthUserId(ctx) 命中一个未映射用户,
|
||||
// 反而绕过 DEV_USER_ID fallback,导致 workspace membership 校验失败。
|
||||
Ok(format!("Convex {admin_key}"))
|
||||
let dev_user_id = config.dev_user_id.trim();
|
||||
if dev_user_id.is_empty() {
|
||||
return Ok(format!("Convex {admin_key}"));
|
||||
}
|
||||
|
||||
// 说明:Rust Web 直接调用 Convex HTTP API 时没有 Next/Convex Auth cookie。
|
||||
// 自托管开发态用 admin auth 携带 acting identity,让 @convex-dev/auth 的
|
||||
// getAuthUserId(ctx) 能得到 DEV_USER_ID,从而和 Next 开发态免登录语义一致。
|
||||
let identity = json!({
|
||||
"subject": format!("{}|mnote-web-dev-session", dev_user_id),
|
||||
"issuer": "mnote-web-dev",
|
||||
"name": config.dev_user_name,
|
||||
"email": config.dev_user_email,
|
||||
});
|
||||
let identity_encoded = base64::engine::general_purpose::STANDARD
|
||||
.encode(identity.to_string().as_bytes());
|
||||
Ok(format!("Convex {admin_key}:{identity_encoded}"))
|
||||
}
|
||||
|
||||
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
||||
@@ -459,7 +476,7 @@ fn now_iso_like() -> String {
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
|
||||
}
|
||||
|
||||
async fn execute_convex_mutation_by_name(
|
||||
pub async fn execute_convex_mutation_by_name(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
function_name: &str,
|
||||
@@ -699,12 +716,16 @@ mod tests {
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
|
||||
use base64::Engine;
|
||||
|
||||
fn config() -> AppConfig {
|
||||
AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
@@ -744,11 +765,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_falls_back_to_plain_admin_auth() {
|
||||
fn build_authorization_falls_back_to_dev_admin_identity() {
|
||||
let authorization = build_authorization(&config(), &request_context(HeaderMap::new()))
|
||||
.expect("authorization");
|
||||
|
||||
assert_eq!(authorization, "Convex admin-demo");
|
||||
assert!(authorization.starts_with("Convex admin-demo:"));
|
||||
let encoded = authorization
|
||||
.trim_start_matches("Convex admin-demo:")
|
||||
.trim();
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.expect("identity base64");
|
||||
let identity: serde_json::Value = serde_json::from_slice(&decoded).expect("identity json");
|
||||
assert_eq!(identity["subject"], "dev-user|mnote-web-dev-session");
|
||||
assert_eq!(identity["issuer"], "mnote-web-dev");
|
||||
assert_eq!(identity["email"], "dev@mnote.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -766,4 +797,36 @@ mod tests {
|
||||
|
||||
assert_eq!(authorization, "Bearer token-from-cookie");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_prefers_convex_auth_jwt_over_legacy_handoff_cookie() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
HeaderValue::from_static(
|
||||
"mnote_web_convex_token=legacy-token; __convexAuthJWT=jwt-from-convex-auth",
|
||||
),
|
||||
);
|
||||
|
||||
let authorization =
|
||||
build_authorization(&config(), &request_context(headers)).expect("authorization");
|
||||
|
||||
assert_eq!(authorization, "Bearer jwt-from-convex-auth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_reads_convex_auth_jwt_cookie() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
HeaderValue::from_static(
|
||||
"foo=bar; __convexAuthJWT=jwt-from-convex-auth; __convexAuthRefreshToken=refresh",
|
||||
),
|
||||
);
|
||||
|
||||
let authorization =
|
||||
build_authorization(&config(), &request_context(headers)).expect("authorization");
|
||||
|
||||
assert_eq!(authorization, "Bearer jwt-from-convex-auth");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,4 @@ impl TreeShellCommandAction {
|
||||
Self::Copy => "tree.subtree.copy",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compat_command_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "documents.create",
|
||||
Self::Rename => "documents.title.update",
|
||||
Self::Move => "documents.move",
|
||||
Self::Archive => "documents.delete",
|
||||
Self::Restore => "documents.restore",
|
||||
Self::Purge => "documents.purge",
|
||||
Self::Embed => "documents.embed",
|
||||
Self::Copy => "documents.copy_tree",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,9 +545,7 @@ mod tests {
|
||||
copy: false,
|
||||
},
|
||||
);
|
||||
assert!(rejected
|
||||
.outputs
|
||||
.contains(&FileTreeRuntimeOutput::DomPatch));
|
||||
assert!(rejected.outputs.contains(&FileTreeRuntimeOutput::DomPatch));
|
||||
assert!(!rejected.outputs.iter().any(|output| matches!(
|
||||
output,
|
||||
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
|
||||
|
||||
@@ -399,8 +399,8 @@ fn transition<const N: usize>(
|
||||
mod tests {
|
||||
use super::{
|
||||
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
|
||||
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeRow,
|
||||
PageTreeRuntimeOutput, PageTreeRuntimeState,
|
||||
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
|
||||
PageTreeRuntimeOutput, PageTreeRuntimeRow, PageTreeRuntimeState,
|
||||
};
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
|
||||
@@ -247,12 +247,10 @@ mod tests {
|
||||
});
|
||||
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
|
||||
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
|
||||
assert!(
|
||||
filetree
|
||||
.filetree_selection
|
||||
.selected_row_ids
|
||||
.contains("asset:a")
|
||||
);
|
||||
assert!(filetree
|
||||
.filetree_selection
|
||||
.selected_row_ids
|
||||
.contains("asset:a"));
|
||||
assert!(filetree.page_focus_keyboard_reducer.is_none());
|
||||
assert_eq!(
|
||||
filetree
|
||||
@@ -334,74 +332,52 @@ mod tests {
|
||||
assert!(artifact.runtime_api.state_snapshots.contains("fileTree"));
|
||||
assert!(artifact.runtime_api.state_snapshots.contains("picker"));
|
||||
assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState"));
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.dom_patch_kinds
|
||||
.contains("fileTreeState")
|
||||
);
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.dom_patch_kinds
|
||||
.contains("fileTreeState"));
|
||||
assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState"));
|
||||
assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen"));
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("fileTreeOpen")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("fileTreeInternalDrop")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("fileTreeExternalDrop")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("pickerPickDocument")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("createNode")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("renameNode")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("moveSubtree")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("copyResource")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("moveResource")
|
||||
);
|
||||
assert!(
|
||||
artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("uploadResource")
|
||||
);
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("fileTreeOpen"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("fileTreeInternalDrop"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("fileTreeExternalDrop"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.host_event_kinds
|
||||
.contains("pickerPickDocument"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("createNode"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("renameNode"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("moveSubtree"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("copyResource"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("moveResource"));
|
||||
assert!(artifact
|
||||
.runtime_api
|
||||
.command_event_kinds
|
||||
.contains("uploadResource"));
|
||||
assert!(artifact.event_kinds.contains("focus"));
|
||||
assert!(artifact.event_kinds.contains("keyboard"));
|
||||
assert!(artifact.event_kinds.contains("expandCollapse"));
|
||||
|
||||
@@ -4,9 +4,8 @@ use super::filetree_runtime::{
|
||||
FileTreeRuntimeState,
|
||||
};
|
||||
use super::page_runtime::{
|
||||
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
|
||||
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
|
||||
PageTreeRuntimeOutput, PageTreeRuntimeState,
|
||||
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeIntentEvent,
|
||||
PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeOutput, PageTreeRuntimeState,
|
||||
};
|
||||
use super::picker_runtime::{
|
||||
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeOutput, PickerRuntimePickTarget,
|
||||
@@ -393,6 +392,7 @@ mod tests {
|
||||
reduce_tree_shell_runtime, TreeShellCommandEvent, TreeShellDomPatch, TreeShellHostEvent,
|
||||
TreeShellRuntimeMode, TreeShellRuntimeRequest, TreeShellRuntimeStateSnapshot,
|
||||
};
|
||||
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
|
||||
use crate::tree_shell::filetree_runtime::{
|
||||
FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeRow,
|
||||
FileTreeRuntimeState,
|
||||
@@ -407,7 +407,6 @@ mod tests {
|
||||
use crate::tree_shell::picker_runtime::{
|
||||
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeState,
|
||||
};
|
||||
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceShellProjection {
|
||||
pub schema: String,
|
||||
pub workspace_id: String,
|
||||
pub workspace_name: String,
|
||||
pub active_page_id: Option<String>,
|
||||
pub active_page_title: Option<String>,
|
||||
pub starred_items: Vec<WorkspaceShellItem>,
|
||||
pub my_page_items: Vec<WorkspaceShellItem>,
|
||||
pub bottom_entries: Vec<WorkspaceShellEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceShellItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub icon: Option<String>,
|
||||
pub href: String,
|
||||
pub depth: u32,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceShellEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub href: String,
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
pub fn build_workspace_shell_projection(
|
||||
dataset: &Value,
|
||||
workspace_id: &str,
|
||||
active_page_id: Option<&str>,
|
||||
default_workspace_name: &str,
|
||||
) -> WorkspaceShellProjection {
|
||||
let active_page_id = active_page_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let documents = dataset
|
||||
.get("documents")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let workspace_name = resolve_workspace_name(dataset, workspace_id)
|
||||
.unwrap_or_else(|| default_workspace_name.trim().to_string())
|
||||
.if_empty_else(|| "个人空间".to_string());
|
||||
|
||||
let mut my_page_items = documents
|
||||
.iter()
|
||||
.filter_map(|document| document_to_item(document, workspace_id, None))
|
||||
.collect::<Vec<_>>();
|
||||
my_page_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||||
|
||||
let active_page_id = active_page_id
|
||||
.or_else(|| {
|
||||
dataset
|
||||
.get("active_page_id")
|
||||
.or_else(|| dataset.get("activePageId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.or_else(|| my_page_items.first().map(|item| item.id.clone()));
|
||||
|
||||
for item in &mut my_page_items {
|
||||
item.active = active_page_id.as_deref().is_some_and(|active_id| active_id == item.id);
|
||||
}
|
||||
|
||||
let mut starred_items = documents
|
||||
.iter()
|
||||
.filter(|document| {
|
||||
document
|
||||
.get("is_starred")
|
||||
.or_else(|| document.get("isStarred"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||||
|
||||
let active_page_title = active_page_id.as_deref().and_then(|active_id| {
|
||||
my_page_items
|
||||
.iter()
|
||||
.find(|item| item.id == active_id)
|
||||
.map(|item| item.title.clone())
|
||||
});
|
||||
|
||||
WorkspaceShellProjection {
|
||||
schema: "mnote.workspace_shell.v1".into(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
workspace_name,
|
||||
active_page_id,
|
||||
active_page_title,
|
||||
starred_items,
|
||||
my_page_items,
|
||||
bottom_entries: vec![
|
||||
WorkspaceShellEntry {
|
||||
id: "trash".into(),
|
||||
label: "垃圾箱".into(),
|
||||
href: "/trash".into(),
|
||||
icon: "🗑".into(),
|
||||
},
|
||||
WorkspaceShellEntry {
|
||||
id: "templates".into(),
|
||||
label: "模板中心".into(),
|
||||
href: "/templates".into(),
|
||||
icon: "📦".into(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_workspace_name(dataset: &Value, workspace_id: &str) -> Option<String> {
|
||||
dataset
|
||||
.get("workspaces")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|workspaces| {
|
||||
workspaces.iter().find_map(|workspace| {
|
||||
let id = workspace
|
||||
.get("id")
|
||||
.or_else(|| workspace.get("workspace_id"))
|
||||
.or_else(|| workspace.get("workspaceId"))
|
||||
.and_then(Value::as_str)?;
|
||||
if id != workspace_id {
|
||||
return None;
|
||||
}
|
||||
workspace
|
||||
.get("name")
|
||||
.or_else(|| workspace.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn document_to_item(
|
||||
document: &Value,
|
||||
workspace_id: &str,
|
||||
active_page_id: Option<&str>,
|
||||
) -> Option<WorkspaceShellItem> {
|
||||
let document_workspace_id = document
|
||||
.get("workspace_id")
|
||||
.or_else(|| document.get("workspaceId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(workspace_id);
|
||||
if document_workspace_id != workspace_id {
|
||||
return None;
|
||||
}
|
||||
if document
|
||||
.get("deleted_at")
|
||||
.or_else(|| document.get("deletedAt"))
|
||||
.is_some_and(|value| !value.is_null())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let id = document
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let title = document
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("无标题");
|
||||
let depth = document
|
||||
.get("depth")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
document
|
||||
.get("parent_id")
|
||||
.or_else(|| document.get("parentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|_| 1)
|
||||
})
|
||||
.unwrap_or(0) as u32;
|
||||
Some(WorkspaceShellItem {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
icon: document
|
||||
.get("icon")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
href: format!("/documents/{id}?workspaceId={workspace_id}"),
|
||||
depth,
|
||||
active: active_page_id.is_some_and(|active_id| active_id == id),
|
||||
})
|
||||
}
|
||||
|
||||
trait StringEmptyExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl StringEmptyExt 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 serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_projection_contains_sidebar_sections_and_bottom_entries() {
|
||||
let dataset = json!({
|
||||
"active_workspace_id": "ws_demo",
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": [
|
||||
{ "id": "page_home", "workspace_id": "ws_demo", "title": "个人", "parent_id": null, "sort_order": 0, "is_starred": true },
|
||||
{ "id": "page_child", "workspace_id": "ws_demo", "title": "软件开发", "parent_id": "page_home", "sort_order": 1, "is_starred": false }
|
||||
]
|
||||
});
|
||||
|
||||
let projection = build_workspace_shell_projection(
|
||||
&dataset,
|
||||
"ws_demo",
|
||||
Some("page_home"),
|
||||
"开发用户 的工作区",
|
||||
);
|
||||
|
||||
assert_eq!(projection.schema, "mnote.workspace_shell.v1");
|
||||
assert_eq!(projection.workspace_id, "ws_demo");
|
||||
assert_eq!(projection.workspace_name, "开发用户 的工作区");
|
||||
assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
|
||||
assert_eq!(projection.active_page_title.as_deref(), Some("个人"));
|
||||
assert_eq!(projection.starred_items.len(), 1);
|
||||
assert_eq!(projection.starred_items[0].title, "个人");
|
||||
assert_eq!(projection.my_page_items.len(), 2);
|
||||
assert!(projection.my_page_items[0].active);
|
||||
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "垃圾箱"));
|
||||
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "模板中心"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn render_workspace_shell_sidebar_html(
|
||||
projection: &WorkspaceShellProjection,
|
||||
sidebar_tree_html: Option<&str>,
|
||||
) -> String {
|
||||
let starred_rows = if projection.starred_items.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
projection
|
||||
.starred_items
|
||||
.iter()
|
||||
.map(render_item_row)
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
};
|
||||
let projected_my_pages = projection
|
||||
.my_page_items
|
||||
.iter()
|
||||
.map(render_item_row)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let tree_html = sidebar_tree_html
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|html| {
|
||||
format!(
|
||||
r#"<div class="sidebar-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree">{html}</div></div>"#
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let my_pages = if projected_my_pages.is_empty() {
|
||||
tree_html
|
||||
} else {
|
||||
format!("{projected_my_pages}{tree_html}")
|
||||
};
|
||||
let bottom_entries = projection
|
||||
.bottom_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
format!(
|
||||
r#"<a href="{}" class="wolai-footer-entry"><span>{}</span>{}</a>"#,
|
||||
escape_html(&entry.href),
|
||||
escape_html(&entry.icon),
|
||||
escape_html(&entry.label),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
format!(
|
||||
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="我的页面"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><span class="wolai-section-add">+</span></div>{my_pages}</section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn render_item_row(item: &WorkspaceShellItem) -> String {
|
||||
let active_class = if item.active { " wolai-active-row" } else { "" };
|
||||
let depth_style = if item.depth == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
|
||||
};
|
||||
format!(
|
||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-node-id="{}"{depth_style}><span class="wolai-row-icon">{}</span>{}</a>"#,
|
||||
escape_html(&item.href),
|
||||
escape_html(&item.id),
|
||||
escape_html(item.icon.as_deref().unwrap_or("▣")),
|
||||
escape_html(&item.title),
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}}
|
||||
Reference in New Issue
Block a user