Implement Rust web sidebar title and tree interactions
This commit is contained in:
@@ -1047,5 +1047,68 @@
|
||||
"task-026"
|
||||
],
|
||||
"note": "树域 Rust family 默认执行面已完成;主 Web 执行面仍需通过 task-019 至 task-026 迁到 mnote-web / Rust family。"
|
||||
},
|
||||
"rust_web_3_4_undo": {
|
||||
"source_doc": "design/03-rust-web/process/3-4-undo.md",
|
||||
"status": "in_progress",
|
||||
"dependency_chain": "A -> B/C -> D/E/F -> G -> H",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-3-4-A",
|
||||
"title": "Page Aggregate 升级为 core-protocol projection contract",
|
||||
"status": "in_progress",
|
||||
"depends_on": [],
|
||||
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol page_aggregate && cargo test -p bridge-runtime page_aggregate && cargo test -p mnote-web page_aggregate"
|
||||
},
|
||||
{
|
||||
"id": "task-3-4-B",
|
||||
"title": "page.* 成为页面默认写命令族",
|
||||
"status": "in_progress",
|
||||
"depends_on": ["task-3-4-A"],
|
||||
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime page_body_save && cargo test -p bridge-runtime page_head_update_title && cargo test -p bridge-runtime page_layout_update_options && cargo test -p mnote-web documents_save"
|
||||
},
|
||||
{
|
||||
"id": "task-3-4-C",
|
||||
"title": "3000 Rust shell 消费 tree realtime event stream",
|
||||
"status": "in_progress",
|
||||
"depends_on": ["task-3-4-A"],
|
||||
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_events && cargo test -p mnote-web tree_command && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js"
|
||||
},
|
||||
{
|
||||
"id": "task-3-4-D",
|
||||
"title": "Search 收口为 server-first / kernel-aware 主链",
|
||||
"status": "in_progress",
|
||||
"depends_on": ["task-3-4-B", "task-3-4-C"],
|
||||
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol search && cargo test -p bridge-runtime search_documents_query && cargo test -p mnote-web search && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task125-rust-web-search-server-first-smoke.js"
|
||||
},
|
||||
{
|
||||
"id": "task-3-4-E",
|
||||
"title": "AI bridge 与结构化写链收口",
|
||||
"status": "in_progress",
|
||||
"depends_on": ["task-3-4-B", "task-3-4-C"],
|
||||
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol ai && cargo test -p mnote-web hermes && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task126-rust-web-ai-bridge-structured-write-smoke.js"
|
||||
},
|
||||
{
|
||||
"id": "task-3-4-F",
|
||||
"title": "Mindmap 成为 kernel projection / command truth",
|
||||
"status": "in_progress",
|
||||
"depends_on": ["task-3-4-B", "task-3-4-C"],
|
||||
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol mindmap && cargo test -p bridge-runtime mindmap && cargo test -p mnote-web mindmap && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task127-rust-web-mindmap-kernel-projection-smoke.js"
|
||||
},
|
||||
{
|
||||
"id": "task-3-4-G",
|
||||
"title": "Legacy Next / React 兼容层退役 gate",
|
||||
"status": "in_progress",
|
||||
"depends_on": ["task-3-4-D", "task-3-4-E", "task-3-4-F"],
|
||||
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web gateway && cargo test -p mnote-web legacy_next && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task117-next-retirement-guard.js"
|
||||
},
|
||||
{
|
||||
"id": "task-3-4-H",
|
||||
"title": "设计文档状态治理与执行节奏",
|
||||
"status": "in_progress",
|
||||
"depends_on": ["task-3-4-G"],
|
||||
"validation": "cd /mnt/Data1T/mnote && rg -n \"\\\\[ \\\\]\" design/03-rust-web/process/3-4-undo.md && rg -n \"\\\\[done\\\\]|\\\\[recycle\\\\]\" design/03-rust-web design/old && git diff --check && git status --short --untracked-files=all"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ use core_protocol::{
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview,
|
||||
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock,
|
||||
PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef,
|
||||
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
|
||||
MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner,
|
||||
MindmapTreeNode, MoveBlock, PageAggregateProjection, PageAggregateSource, PageBody, PageHead,
|
||||
PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, PatchBlock,
|
||||
PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef,
|
||||
ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats,
|
||||
};
|
||||
use event_log::DomainEventRecord;
|
||||
@@ -412,6 +415,13 @@ struct DocumentContentQueryPayload {
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PageAggregateQueryPayload {
|
||||
document_id: String,
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MindmapGetQueryPayload {
|
||||
@@ -686,6 +696,19 @@ struct MindmapPutCommandPayload {
|
||||
create_only: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MindmapCommandApplyPayload {
|
||||
document_id: String,
|
||||
mindmap_id: String,
|
||||
#[serde(default)]
|
||||
workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
commands: Vec<MindmapCommand>,
|
||||
#[serde(default)]
|
||||
projection_revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MindmapDeleteCommandPayload {
|
||||
@@ -2927,10 +2950,39 @@ fn execute_query(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"mindmaps.get" => {
|
||||
let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?;
|
||||
"page.aggregate.get" => {
|
||||
let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let query = QueryEnvelope {
|
||||
name: "mindmaps.get".into(),
|
||||
name: "page.aggregate.get".into(),
|
||||
payload: core_protocol::GetPageContent {
|
||||
page_id: payload.document_id.clone(),
|
||||
workspace_id: payload.workspace_id.clone(),
|
||||
},
|
||||
};
|
||||
let request = build_query_request(&context, &query)?;
|
||||
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
|
||||
query_name: query.name,
|
||||
function_name: request.function_name,
|
||||
workspace_id: request.workspace_id,
|
||||
request_id: request.request_id,
|
||||
trace_id: request.trace_id,
|
||||
actor_id: request.actor_id,
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"documentId": payload.document_id,
|
||||
"workspaceId": payload.workspace_id,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"mindmaps.get" | "mindmap.projection.get" => {
|
||||
let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let query_name = if query_wire.name == "mindmap.projection.get" {
|
||||
"mindmap.projection.get"
|
||||
} else {
|
||||
"mindmaps.get"
|
||||
};
|
||||
let query = QueryEnvelope {
|
||||
name: query_name.into(),
|
||||
payload: GetMindmap {
|
||||
document_id: payload.document_id.clone(),
|
||||
mindmap_id: payload.mindmap_id.clone(),
|
||||
@@ -2997,15 +3049,20 @@ fn execute_query(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"search.documents" => {
|
||||
"search.documents" | "search.documents.query" => {
|
||||
let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let query_name = if query_wire.name == "search.documents.query" {
|
||||
"search.documents.query"
|
||||
} else {
|
||||
"search.documents"
|
||||
};
|
||||
let time_range = payload.time_range.clone().unwrap_or_else(|| "any".into());
|
||||
let time_field = payload
|
||||
.time_field
|
||||
.clone()
|
||||
.unwrap_or_else(|| "updated".into());
|
||||
let query = QueryEnvelope {
|
||||
name: "search.documents".into(),
|
||||
name: query_name.into(),
|
||||
payload: SearchDocuments {
|
||||
query: payload.query.clone(),
|
||||
workspace_id: payload.workspace_id.clone(),
|
||||
@@ -5104,6 +5161,157 @@ fn build_document_content_result(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_page_aggregate_projection_result(
|
||||
data: &Value,
|
||||
document_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
source: PageAggregateSource,
|
||||
) -> Result<PageAggregateProjection, BridgeError> {
|
||||
if data.is_null() {
|
||||
return Err(BridgeError::not_found("page.aggregate.get 未返回页面聚合数据"));
|
||||
}
|
||||
|
||||
let meta = data.get("meta").unwrap_or(data);
|
||||
let content_result = data
|
||||
.get("content")
|
||||
.filter(|value| {
|
||||
value.get("content").is_some()
|
||||
|| value.get("revision").is_some()
|
||||
|| value.get("pageSubtree").is_some()
|
||||
|| value.get("page_subtree").is_some()
|
||||
})
|
||||
.unwrap_or(data);
|
||||
let resolved_document_id = read_trimmed_string_field(meta, &["id", "documentId"])
|
||||
.unwrap_or_else(|| document_id.to_string());
|
||||
let resolved_workspace_id = read_trimmed_string_field(meta, &["workspace_id", "workspaceId"])
|
||||
.or_else(|| workspace_id.map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "default".into());
|
||||
let title = read_trimmed_string_field(meta, &["title"]).unwrap_or_else(|| "无标题".into());
|
||||
let updated_at = read_trimmed_string_field(meta, &["updated_at", "updatedAt"]);
|
||||
let parent_id = read_trimmed_string_field(meta, &["parent_id", "parentId"]);
|
||||
let content = content_result
|
||||
.get("content")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::Array(vec![]));
|
||||
let revision = content_result
|
||||
.get("revision")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let conflict_detection_key = content_result
|
||||
.get("conflictDetectionKey")
|
||||
.or_else(|| content_result.get("conflict_detection_key"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let page_subtree = content_result
|
||||
.get("pageSubtree")
|
||||
.or_else(|| content_result.get("page_subtree"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({ "rootNodeId": resolved_document_id }));
|
||||
let page_options = PageOptions {
|
||||
wide_layout: bool_field(meta, "wide_layout")
|
||||
.or_else(|| bool_field(meta, "wideLayout"))
|
||||
.unwrap_or(false),
|
||||
small_text: bool_field(meta, "use_small_text")
|
||||
.or_else(|| bool_field(meta, "smallText"))
|
||||
.unwrap_or(false),
|
||||
layout_density: read_trimmed_string_field(meta, &["layout_density", "layoutDensity"])
|
||||
.unwrap_or_else(|| "normal".into()),
|
||||
show_heading_numbers: bool_field(meta, "show_heading_numbers")
|
||||
.or_else(|| bool_field(meta, "showHeadingNumbers"))
|
||||
.unwrap_or(true),
|
||||
show_toc: bool_field(meta, "show_toc")
|
||||
.or_else(|| bool_field(meta, "showToc"))
|
||||
.unwrap_or(false),
|
||||
show_structure: bool_field(meta, "show_structure")
|
||||
.or_else(|| bool_field(meta, "showStructure"))
|
||||
.unwrap_or(false),
|
||||
protect_editing: bool_field(meta, "protect_editing")
|
||||
.or_else(|| bool_field(meta, "protectEditing"))
|
||||
.unwrap_or(false),
|
||||
show_word_count: bool_field(meta, "show_word_count")
|
||||
.or_else(|| bool_field(meta, "showWordCount"))
|
||||
.unwrap_or(true),
|
||||
collapse_backlinks: bool_field(meta, "collapse_backlinks")
|
||||
.or_else(|| bool_field(meta, "collapseBacklinks"))
|
||||
.unwrap_or(false),
|
||||
page_font: read_trimmed_string_field(meta, &["page_font", "pageFont"])
|
||||
.unwrap_or_else(|| "default".into()),
|
||||
hide_child_pages: bool_field(meta, "hide_child_pages")
|
||||
.or_else(|| bool_field(meta, "hideChildPages"))
|
||||
.unwrap_or(false),
|
||||
show_block_ref_count: bool_field(meta, "show_block_ref_count")
|
||||
.or_else(|| bool_field(meta, "showBlockRefCount"))
|
||||
.unwrap_or(false),
|
||||
embed_default_block_id: meta
|
||||
.get("embed_default_block_id")
|
||||
.or_else(|| meta.get("embedDefaultBlockId"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
};
|
||||
let layout_options = serde_json::to_value(&page_options).map_err(|error| {
|
||||
BridgeError::transport(format!("PageOptions 序列化失败: {error}"))
|
||||
})?;
|
||||
let revision_ref = revision
|
||||
.as_u64()
|
||||
.map(|value| format!("{resolved_document_id}:{value}"));
|
||||
|
||||
Ok(PageAggregateProjection {
|
||||
schema: PageAggregateProjection::SCHEMA.into(),
|
||||
projection_version: PageAggregateProjection::VERSION,
|
||||
source,
|
||||
page_id: resolved_document_id.clone(),
|
||||
parent_id,
|
||||
title: title.clone(),
|
||||
path: vec![resolved_document_id.clone()],
|
||||
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
|
||||
body_ref: revision_ref,
|
||||
layout_options,
|
||||
updated_at: updated_at.clone(),
|
||||
identity: PageIdentity {
|
||||
document_id: resolved_document_id,
|
||||
workspace_id: resolved_workspace_id,
|
||||
},
|
||||
head: PageHead {
|
||||
title,
|
||||
updated_at: updated_at.map(Value::String).unwrap_or(Value::Null),
|
||||
permissions: PagePermissions {
|
||||
read_only: bool_field(meta, "can_edit").map(|can_edit| !can_edit).unwrap_or(false),
|
||||
disable_download: bool_field(meta, "disable_download")
|
||||
.or_else(|| bool_field(meta, "disableDownload"))
|
||||
.unwrap_or(false),
|
||||
disable_copy: bool_field(meta, "disable_copy")
|
||||
.or_else(|| bool_field(meta, "disableCopy"))
|
||||
.unwrap_or(false),
|
||||
},
|
||||
},
|
||||
layout: PageLayout { page_options },
|
||||
body: PageBody {
|
||||
content,
|
||||
revision,
|
||||
conflict_detection_key,
|
||||
},
|
||||
tree: PageTree { page_subtree },
|
||||
stats: PageStats {
|
||||
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: meta
|
||||
.get("todo_total")
|
||||
.or_else(|| meta.get("todo_total_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
todo_done: meta
|
||||
.get("todo_done")
|
||||
.or_else(|| meta.get("todo_done_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_mindmap_from_value(data: &Value) -> Result<MindmapTreeNode, BridgeError> {
|
||||
if data.is_null() {
|
||||
return Ok(default_mindmap_tree());
|
||||
@@ -5131,6 +5339,98 @@ fn normalize_mindmap_from_value(data: &Value) -> Result<MindmapTreeNode, BridgeE
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
fn build_mindmap_projection_result(
|
||||
data: &Value,
|
||||
mindmap_id: &str,
|
||||
) -> Result<MindmapProjection, BridgeError> {
|
||||
let revision = data
|
||||
.get("revision")
|
||||
.or_else(|| data.get("meta").and_then(|meta| meta.get("revision")))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1);
|
||||
let tree_input = data
|
||||
.get("data")
|
||||
.filter(|value| value.get("data").is_some() || value.get("children").is_some())
|
||||
.unwrap_or(data);
|
||||
let tree = normalize_mindmap_from_value(tree_input)?;
|
||||
let root_node = tree
|
||||
.data
|
||||
.uid
|
||||
.clone()
|
||||
.unwrap_or_else(|| "root".into());
|
||||
let mut nodes = Vec::new();
|
||||
let mut edges = Vec::new();
|
||||
collect_mindmap_projection_rows(&tree, None, &mut nodes, &mut edges);
|
||||
|
||||
Ok(MindmapProjection {
|
||||
schema: "mnote.mindmap_projection.v1".into(),
|
||||
map_id: mindmap_id.into(),
|
||||
root_node,
|
||||
nodes,
|
||||
edges,
|
||||
layout_hints: data
|
||||
.get("layoutHints")
|
||||
.or_else(|| data.get("layout_hints"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({ "layout": "right" })),
|
||||
revision,
|
||||
owner: MindmapProjectionOwner::RustKernel,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_mindmap_projection_rows(
|
||||
node: &MindmapTreeNode,
|
||||
parent_id: Option<&str>,
|
||||
nodes: &mut Vec<MindmapProjectionNode>,
|
||||
edges: &mut Vec<MindmapProjectionEdge>,
|
||||
) {
|
||||
let node_id = node
|
||||
.data
|
||||
.uid
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("node_{}", nodes.len() + 1));
|
||||
let title = node
|
||||
.data
|
||||
.text
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "未命名节点".into());
|
||||
if let Some(parent_id) = parent_id {
|
||||
edges.push(MindmapProjectionEdge {
|
||||
edge_id: format!("{parent_id}->{node_id}"),
|
||||
from_node_id: parent_id.into(),
|
||||
to_node_id: node_id.clone(),
|
||||
edge_kind: "mindmap_child".into(),
|
||||
});
|
||||
}
|
||||
nodes.push(MindmapProjectionNode {
|
||||
node_id: node_id.clone(),
|
||||
title,
|
||||
parent_id: parent_id.map(ToOwned::to_owned),
|
||||
});
|
||||
for child in &node.children {
|
||||
collect_mindmap_projection_rows(child, Some(&node_id), nodes, edges);
|
||||
}
|
||||
}
|
||||
|
||||
fn search_documents_canonical_projection(evaluation: SearchDocumentsEvaluation) -> Value {
|
||||
let mut payload = serde_json::to_value(evaluation).unwrap_or_else(|_| json!({
|
||||
"enqueueAssetIds": [],
|
||||
"results": []
|
||||
}));
|
||||
if let Some(map) = payload.as_object_mut() {
|
||||
map.insert("projectionOwner".into(), json!("rust-kernel"));
|
||||
if let Some(results) = map.get_mut("results").and_then(Value::as_array_mut) {
|
||||
for result in results {
|
||||
if let Some(result_map) = result.as_object_mut() {
|
||||
result_map.insert("projectionOwner".into(), json!("rust-kernel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn walk_block_summaries(root_blocks: &[Value], max_nodes: usize) -> Vec<RuntimeBlockSummary> {
|
||||
let mut queue = VecDeque::new();
|
||||
for block in root_blocks {
|
||||
@@ -7082,6 +7382,18 @@ fn execute_query_result(
|
||||
BridgeError::transport(format!("documents.content.get result 序列化失败: {error}"))
|
||||
})
|
||||
}
|
||||
"page.aggregate.get" => {
|
||||
let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let result = build_page_aggregate_projection_result(
|
||||
&data,
|
||||
&payload.document_id,
|
||||
payload.workspace_id.as_deref(),
|
||||
PageAggregateSource::KernelProjection,
|
||||
)?;
|
||||
serde_json::to_value(result).map_err(|error| {
|
||||
BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}"))
|
||||
})
|
||||
}
|
||||
"kernel.node.get" => {
|
||||
let payload: KernelGetNodeQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let node = normalize_kernel_node_from_record(&data, payload.workspace_id.as_deref())?;
|
||||
@@ -7167,7 +7479,16 @@ fn execute_query_result(
|
||||
BridgeError::transport(format!("mindmaps.get result 序列化失败: {error}"))
|
||||
})
|
||||
}
|
||||
"search.documents" => {
|
||||
"mindmap.projection.get" => {
|
||||
let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let result = build_mindmap_projection_result(&data, &payload.mindmap_id)?;
|
||||
serde_json::to_value(result).map_err(|error| {
|
||||
BridgeError::transport(format!(
|
||||
"mindmap.projection.get result 序列化失败: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
"search.documents" | "search.documents.query" => {
|
||||
let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let dataset: SearchDocumentsDataset =
|
||||
serde_json::from_value(data).map_err(|error| {
|
||||
@@ -7191,10 +7512,15 @@ fn execute_query_result(
|
||||
},
|
||||
&dataset,
|
||||
);
|
||||
serde_json::to_value(SearchDocumentsEvaluation {
|
||||
let legacy_result = SearchDocumentsEvaluation {
|
||||
enqueue_asset_ids: result.enqueue_asset_ids,
|
||||
results: result.results,
|
||||
})
|
||||
};
|
||||
if query_wire.name == "search.documents.query" {
|
||||
serde_json::to_value(search_documents_canonical_projection(legacy_result))
|
||||
} else {
|
||||
serde_json::to_value(legacy_result)
|
||||
}
|
||||
.map_err(|error| {
|
||||
BridgeError::transport(format!("search.documents result 序列化失败: {error}"))
|
||||
})
|
||||
@@ -7985,6 +8311,45 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"mindmap.command.apply" => {
|
||||
let payload: MindmapCommandApplyPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let command = CommandEnvelope {
|
||||
name: "mindmap.command.apply".into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
source: to_source_payload(&command_wire.source),
|
||||
target: to_target_ref(command_wire.target.as_ref()).or(Some(TargetRef {
|
||||
workspace_id: payload.workspace_id.clone(),
|
||||
page_id: Some(payload.document_id.clone()),
|
||||
block_id: None,
|
||||
})),
|
||||
payload: payload.commands.clone(),
|
||||
reason: command_wire.reason,
|
||||
refs: command_wire.refs,
|
||||
dry_run: command_wire.dry_run,
|
||||
validate_only: command_wire.validate_only,
|
||||
};
|
||||
let request = build_write_request(&context, &command)?;
|
||||
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
|
||||
command_name: command.name,
|
||||
command_id: command.command_id,
|
||||
function_name: request.function_name,
|
||||
workspace_id: request.workspace_id,
|
||||
request_id: request.request_id,
|
||||
trace_id: request.trace_id,
|
||||
actor_id: request.actor_id,
|
||||
idempotency_key: request.idempotency_key,
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"documentId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"commands": payload.commands,
|
||||
"projectionRevision": payload.projection_revision,
|
||||
"canonicalCommand": "mindmap.command.apply",
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"mindmaps.delete" => {
|
||||
let payload: MindmapDeleteCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let command = CommandEnvelope {
|
||||
@@ -9333,6 +9698,164 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_aggregate_get_query_executes_into_core_projection() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "page.aggregate.get".into(),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"meta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "聚合页面",
|
||||
"parent_id": "root",
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
},
|
||||
"content": {
|
||||
"content": [],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1"}
|
||||
}
|
||||
})),
|
||||
})
|
||||
.expect("page aggregate query should build");
|
||||
|
||||
assert_eq!(result["schema"], json!("mnote.page_aggregate.v1"));
|
||||
assert_eq!(result["projectionVersion"], json!(1));
|
||||
assert_eq!(result["source"], json!("KernelProjection"));
|
||||
assert_eq!(result["pageId"], json!("doc_1"));
|
||||
assert_eq!(result["identity"]["documentId"], json!("doc_1"));
|
||||
assert_eq!(result["body"]["revision"], json!(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_documents_query_canonical_facade_keeps_projection_owner() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "search.documents.query".into(),
|
||||
payload: json!({
|
||||
"query": "rust",
|
||||
"workspaceId": "ws_1",
|
||||
"pageId": null,
|
||||
"limit": 10,
|
||||
"titleOnly": false,
|
||||
"exact": false,
|
||||
"includeOcr": true,
|
||||
"timeRange": "any",
|
||||
"timeField": "updated",
|
||||
"customRangeFrom": null,
|
||||
"customRangeTo": null,
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_1",
|
||||
"workspaceId": "ws_1",
|
||||
"title": "Rust Notes",
|
||||
"rawText": "这里有 rust 搜索内容",
|
||||
"createdAt": "2026-04-15T00:00:00Z",
|
||||
"updatedAt": "2026-04-15T01:00:00Z"
|
||||
}
|
||||
],
|
||||
"mindmaps": [],
|
||||
"tables": [],
|
||||
"tableRows": [],
|
||||
"assets": []
|
||||
})),
|
||||
})
|
||||
.expect("canonical search query result should build");
|
||||
|
||||
assert_eq!(result["projectionOwner"], json!("rust-kernel"));
|
||||
assert_eq!(result["results"][0]["projectionOwner"], json!("rust-kernel"));
|
||||
assert_eq!(result["results"][0]["id"], json!("page_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_projection_get_query_returns_kernel_projection() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "mindmap.projection.get".into(),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"workspaceId": "ws_1",
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"data": {
|
||||
"data": {"uid": "root", "text": "中心主题"},
|
||||
"children": [
|
||||
{"data": {"uid": "child_1", "text": "子节点"}, "children": []}
|
||||
]
|
||||
},
|
||||
"revision": 3
|
||||
})),
|
||||
})
|
||||
.expect("mindmap projection should build");
|
||||
|
||||
assert_eq!(result["schema"], json!("mnote.mindmap_projection.v1"));
|
||||
assert_eq!(result["owner"], json!("rust-kernel"));
|
||||
assert_eq!(result["mapId"], json!("mind_1"));
|
||||
assert_eq!(result["revision"], json!(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_command_apply_plan_uses_kernel_command_facade() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "mindmap.command.apply".into(),
|
||||
command_id: "cmd_mindmap_apply".into(),
|
||||
idempotency_key: None,
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: None,
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: None,
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"commands": [
|
||||
{"type": "renameNode", "mapId": "mind_1", "nodeId": "root", "title": "新标题"}
|
||||
],
|
||||
"projectionRevision": 3
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mindmap command facade".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("mindmap command facade should plan");
|
||||
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(plan.command_name, "mindmap.command.apply");
|
||||
assert_eq!(plan.function_name, "mindmaps:applyCommand");
|
||||
assert_eq!(plan.args_json["canonicalCommand"], json!("mindmap.command.apply"));
|
||||
}
|
||||
RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => {
|
||||
panic!("expected command plan")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_documents_query_executes_in_rust_runtime() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
@@ -10064,6 +10587,128 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_body_save_command_plan_uses_canonical_page_command() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "page.body.save".into(),
|
||||
command_id: "cmd_page_body_save".into(),
|
||||
idempotency_key: None,
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: None,
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 1,
|
||||
"content": [],
|
||||
"conflictDetectionKey": "doc_1:1"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("canonical body save".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("page.body.save plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
assert_eq!(plan.command_name, "page.body.save");
|
||||
assert_eq!(plan.function_name, "documents:updateContent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_head_update_title_command_plan_uses_canonical_page_command() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "page.head.updateTitle".into(),
|
||||
command_id: "cmd_page_head_title".into(),
|
||||
idempotency_key: None,
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: None,
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: None,
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"title": "新标题"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("canonical title update".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("page.head.updateTitle plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
assert_eq!(plan.command_name, "page.head.updateTitle");
|
||||
assert_eq!(plan.function_name, "documents:updateTitle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_update_options_command_plan_uses_canonical_page_command() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "page.layout.updateOptions".into(),
|
||||
command_id: "cmd_page_layout_options".into(),
|
||||
idempotency_key: None,
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: None,
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: None,
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"options": {"showToc": true}
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("canonical layout update".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("page.layout.updateOptions plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
assert_eq!(plan.command_name, "page.layout.updateOptions");
|
||||
assert_eq!(plan.function_name, "documents:updateOptions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_falls_back_from_invalid_editor_document_to_tiptap() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiSession {
|
||||
pub session_id: String,
|
||||
pub workspace_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_id: Option<String>,
|
||||
pub owner: AiRuntimeOwner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AiRuntimeOwner {
|
||||
RustWebHermes,
|
||||
CompatReactIsland,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiToolCall {
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiEvent {
|
||||
pub event_id: String,
|
||||
pub session_id: String,
|
||||
pub kind: AiEventKind,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AiEventKind {
|
||||
SessionCreated,
|
||||
ToolCallPlanned,
|
||||
ToolCallApplied,
|
||||
StructuredWriteCompleted,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiStructuredWriteResult {
|
||||
pub ok: bool,
|
||||
pub write_kind: AiStructuredWriteKind,
|
||||
pub command_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AiStructuredWriteKind {
|
||||
SummaryNode,
|
||||
AiNoteNode,
|
||||
ReferenceEdge,
|
||||
PageBody,
|
||||
}
|
||||
@@ -20,7 +20,8 @@ pub use model::{
|
||||
};
|
||||
pub use tiptap::{
|
||||
EditorBlockDocumentTiptapBridge, EditorBlockDocumentTiptapError, TiptapBlockType,
|
||||
TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapNode, TiptapTaskItemAttrs,
|
||||
TiptapBlockquoteAttrs, TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapListAttrs,
|
||||
TiptapListItemAttrs, TiptapMark, TiptapNode, TiptapParagraphAttrs, TiptapTaskItemAttrs,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
pub mod command;
|
||||
pub mod common;
|
||||
pub mod ai;
|
||||
pub mod editor;
|
||||
pub mod governance;
|
||||
pub mod kernel;
|
||||
pub mod mindmap;
|
||||
pub mod page_aggregate;
|
||||
pub mod query;
|
||||
pub mod search;
|
||||
pub mod tool;
|
||||
|
||||
pub use ai::{
|
||||
AiEvent, AiEventKind, AiRuntimeOwner, AiSession, AiStructuredWriteKind,
|
||||
AiStructuredWriteResult, AiToolCall,
|
||||
};
|
||||
pub use command::{
|
||||
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||
DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock,
|
||||
@@ -42,12 +49,23 @@ pub use kernel::{
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
||||
};
|
||||
pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode};
|
||||
pub use mindmap::{
|
||||
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
|
||||
MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner,
|
||||
MindmapTreeNode,
|
||||
};
|
||||
pub use page_aggregate::{
|
||||
PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout,
|
||||
PageOptions, PagePermissions, PageStats, PageTree,
|
||||
};
|
||||
pub use query::{
|
||||
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset,
|
||||
QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||
};
|
||||
pub use search::{
|
||||
SearchHighlight, SearchProjectionOwner, SearchQuery, SearchResultProjection, SearchScope,
|
||||
};
|
||||
pub use tool::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||
@@ -232,4 +250,116 @@ mod tests {
|
||||
assert!(recovery.write_toolset);
|
||||
assert_eq!(recovery.tool_names, &["event_replay", "index_rebuild"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_aggregate_projection_contract_covers_kernel_owned_fields() {
|
||||
let projection = PageAggregateProjection {
|
||||
schema: "mnote.page_aggregate.v1".into(),
|
||||
projection_version: 1,
|
||||
source: PageAggregateSource::KernelProjection,
|
||||
page_id: "page_1".into(),
|
||||
parent_id: Some("root".into()),
|
||||
title: "Rust 页面".into(),
|
||||
path: vec!["root".into(), "page_1".into()],
|
||||
sidebar_tree_membership: vec!["workspace-sidebar".into()],
|
||||
body_ref: Some("page_1:body".into()),
|
||||
layout_options: serde_json::json!({"wideLayout": true}),
|
||||
updated_at: Some("2026-04-29T00:00:00Z".into()),
|
||||
identity: page_aggregate::PageIdentity {
|
||||
document_id: "page_1".into(),
|
||||
workspace_id: "ws_demo".into(),
|
||||
},
|
||||
head: page_aggregate::PageHead {
|
||||
title: "Rust 页面".into(),
|
||||
updated_at: serde_json::json!("2026-04-29T00:00:00Z"),
|
||||
permissions: page_aggregate::PagePermissions {
|
||||
read_only: false,
|
||||
disable_download: false,
|
||||
disable_copy: false,
|
||||
},
|
||||
},
|
||||
layout: page_aggregate::PageLayout {
|
||||
page_options: page_aggregate::PageOptions::default(),
|
||||
},
|
||||
body: page_aggregate::PageBody {
|
||||
content: serde_json::json!([]),
|
||||
revision: serde_json::json!(7),
|
||||
conflict_detection_key: serde_json::json!("page_1:7"),
|
||||
},
|
||||
tree: page_aggregate::PageTree {
|
||||
page_subtree: serde_json::json!({"rootNodeId": "page_1"}),
|
||||
},
|
||||
stats: page_aggregate::PageStats::default(),
|
||||
};
|
||||
|
||||
assert_eq!(projection.source, PageAggregateSource::KernelProjection);
|
||||
assert_eq!(projection.projection_version, 1);
|
||||
assert_eq!(projection.page_id, "page_1");
|
||||
assert_eq!(projection.identity.document_id, "page_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_projection_contract_marks_owner_and_scope() {
|
||||
let result = SearchResultProjection {
|
||||
id: "page_1".into(),
|
||||
title: "Rust 搜索".into(),
|
||||
snippet: "Rust Web".into(),
|
||||
updated_at: Some("2026-04-29T00:00:00Z".into()),
|
||||
created_at: None,
|
||||
match_field: "title".into(),
|
||||
has_ocr: false,
|
||||
public_path: "/documents/page_1".into(),
|
||||
node_id: Some("page_1".into()),
|
||||
subtree_root_id: Some("page_1".into()),
|
||||
evidence: vec![],
|
||||
score: 3.0,
|
||||
projection_owner: SearchProjectionOwner::RustKernel,
|
||||
};
|
||||
let query = SearchQuery {
|
||||
query: "Rust".into(),
|
||||
workspace_id: "ws_demo".into(),
|
||||
scope: SearchScope::Workspace,
|
||||
page_id: None,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
assert_eq!(query.scope, SearchScope::Workspace);
|
||||
assert_eq!(result.projection_owner, SearchProjectionOwner::RustKernel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_protocol_contract_exposes_structured_write_result() {
|
||||
let result = AiStructuredWriteResult {
|
||||
ok: true,
|
||||
write_kind: AiStructuredWriteKind::SummaryNode,
|
||||
command_name: "tree.node.create".into(),
|
||||
target_id: Some("summary_1".into()),
|
||||
revision: Some(2),
|
||||
};
|
||||
|
||||
assert_eq!(result.write_kind, AiStructuredWriteKind::SummaryNode);
|
||||
assert_eq!(result.command_name, "tree.node.create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_projection_and_command_contract_cover_kernel_truth() {
|
||||
let projection = MindmapProjection {
|
||||
schema: "mnote.mindmap_projection.v1".into(),
|
||||
map_id: "mind_1".into(),
|
||||
root_node: "root".into(),
|
||||
nodes: vec![],
|
||||
edges: vec![],
|
||||
layout_hints: serde_json::json!({"layout": "right"}),
|
||||
revision: 1,
|
||||
owner: MindmapProjectionOwner::RustKernel,
|
||||
};
|
||||
let command = MindmapCommand::RenameNode {
|
||||
map_id: "mind_1".into(),
|
||||
node_id: "root".into(),
|
||||
title: "新标题".into(),
|
||||
};
|
||||
|
||||
assert_eq!(projection.owner, MindmapProjectionOwner::RustKernel);
|
||||
assert!(matches!(command, MindmapCommand::RenameNode { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,96 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapProjection {
|
||||
pub schema: String,
|
||||
pub map_id: String,
|
||||
pub root_node: String,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<MindmapProjectionNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<MindmapProjectionEdge>,
|
||||
#[serde(default)]
|
||||
pub layout_hints: Value,
|
||||
pub revision: u64,
|
||||
pub owner: MindmapProjectionOwner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapProjectionNode {
|
||||
pub node_id: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapProjectionEdge {
|
||||
pub edge_id: String,
|
||||
pub from_node_id: String,
|
||||
pub to_node_id: String,
|
||||
pub edge_kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum MindmapProjectionOwner {
|
||||
RustKernel,
|
||||
CompatBlob,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum MindmapCommand {
|
||||
CreateNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "parentId", alias = "parent_id")]
|
||||
parent_id: String,
|
||||
node: MindmapNodeInput,
|
||||
},
|
||||
RenameNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
title: String,
|
||||
},
|
||||
MoveNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
#[serde(rename = "newParentId", alias = "new_parent_id")]
|
||||
new_parent_id: String,
|
||||
#[serde(rename = "sortOrder", alias = "sort_order")]
|
||||
sort_order: Option<i64>,
|
||||
},
|
||||
DeleteNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
},
|
||||
SetLayout {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "layoutHints", alias = "layout_hints")]
|
||||
layout_hints: Value,
|
||||
},
|
||||
AttachPageRef {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
#[serde(rename = "pageId", alias = "page_id")]
|
||||
page_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapNodeRef {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PageAggregateSource {
|
||||
KernelProjection,
|
||||
CompatMetaContentJoin,
|
||||
Fixture,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageAggregateProjection {
|
||||
pub schema: String,
|
||||
pub projection_version: u32,
|
||||
pub source: PageAggregateSource,
|
||||
pub page_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub path: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub sidebar_tree_membership: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body_ref: Option<String>,
|
||||
pub layout_options: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
pub identity: PageIdentity,
|
||||
pub head: PageHead,
|
||||
pub layout: PageLayout,
|
||||
pub body: PageBody,
|
||||
pub tree: PageTree,
|
||||
pub stats: PageStats,
|
||||
}
|
||||
|
||||
impl PageAggregateProjection {
|
||||
pub const SCHEMA: &'static str = "mnote.page_aggregate.v1";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
pub fn source_label(&self) -> &'static str {
|
||||
match self.source {
|
||||
PageAggregateSource::KernelProjection => "rust-kernel",
|
||||
PageAggregateSource::CompatMetaContentJoin => "compat-join",
|
||||
PageAggregateSource::Fixture => "fixture",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageIdentity {
|
||||
pub document_id: String,
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageHead {
|
||||
pub title: String,
|
||||
pub updated_at: Value,
|
||||
pub permissions: PagePermissions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PagePermissions {
|
||||
pub read_only: bool,
|
||||
pub disable_download: bool,
|
||||
pub disable_copy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageLayout {
|
||||
pub page_options: PageOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl Default for PageOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wide_layout: false,
|
||||
small_text: false,
|
||||
layout_density: "normal".into(),
|
||||
show_heading_numbers: true,
|
||||
show_toc: false,
|
||||
show_structure: false,
|
||||
protect_editing: false,
|
||||
show_word_count: true,
|
||||
collapse_backlinks: false,
|
||||
page_font: "default".into(),
|
||||
hide_child_pages: false,
|
||||
show_block_ref_count: false,
|
||||
embed_default_block_id: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageBody {
|
||||
pub content: Value,
|
||||
pub revision: Value,
|
||||
pub conflict_detection_key: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageTree {
|
||||
pub page_subtree: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[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,63 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchQuery {
|
||||
pub query: String,
|
||||
pub workspace_id: String,
|
||||
pub scope: SearchScope,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_id: Option<String>,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope {
|
||||
Workspace,
|
||||
CurrentPage,
|
||||
Pinned,
|
||||
Recent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResultProjection {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
pub match_field: String,
|
||||
pub has_ocr: bool,
|
||||
pub public_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subtree_root_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub evidence: Vec<SearchHighlight>,
|
||||
pub score: f64,
|
||||
pub projection_owner: SearchProjectionOwner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchHighlight {
|
||||
pub kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<String>,
|
||||
pub snippet: String,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub meta: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SearchProjectionOwner {
|
||||
RustKernel,
|
||||
CompatIndex,
|
||||
}
|
||||
@@ -42,7 +42,7 @@ impl AppConfig {
|
||||
.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_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false),
|
||||
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
|
||||
@@ -1,136 +1,11 @@
|
||||
//! 类型安全的 Page Aggregate 结构体,替代 serde_json::Value 的临时实现。
|
||||
//! Page Aggregate HTTP/SSR adapter。
|
||||
//!
|
||||
//! 参考契约文档: design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md
|
||||
//! 核心契约定义在 `core-protocol`;mnote-web 仅保留 builder 便于
|
||||
//! 迁移期把 meta/content join 适配为同一个 projection。
|
||||
|
||||
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,
|
||||
}
|
||||
pub use core_protocol::{
|
||||
PageAggregateProjection as PageAggregate, PageAggregateSource, PageBody, PageHead,
|
||||
PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree,
|
||||
};
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
//! ```
|
||||
|
||||
use crate::page_aggregate::{
|
||||
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions,
|
||||
PageStats, PageTree,
|
||||
PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
|
||||
PagePermissions, PageStats, PageTree,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{to_value, Value};
|
||||
|
||||
/// PageAggregate 构建器。
|
||||
///
|
||||
@@ -23,6 +23,11 @@ use serde_json::Value;
|
||||
pub struct PageAggregateBuilder {
|
||||
document_id: String,
|
||||
workspace_id: String,
|
||||
source: PageAggregateSource,
|
||||
parent_id: Option<String>,
|
||||
path: Vec<String>,
|
||||
sidebar_tree_membership: Vec<String>,
|
||||
body_ref: Option<String>,
|
||||
title: String,
|
||||
updated_at: Value,
|
||||
read_only: bool,
|
||||
@@ -58,6 +63,11 @@ impl PageAggregateBuilder {
|
||||
Self {
|
||||
document_id: String::new(),
|
||||
workspace_id: "default".to_string(),
|
||||
source: PageAggregateSource::CompatMetaContentJoin,
|
||||
parent_id: None,
|
||||
path: Vec::new(),
|
||||
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
|
||||
body_ref: None,
|
||||
title: "无标题".to_string(),
|
||||
updated_at: Value::Null,
|
||||
read_only: false,
|
||||
@@ -100,6 +110,31 @@ impl PageAggregateBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn source(mut self, value: PageAggregateSource) -> Self {
|
||||
self.source = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn parent_id(mut self, value: Option<String>) -> Self {
|
||||
self.parent_id = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn path(mut self, value: Vec<String>) -> Self {
|
||||
self.path = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn sidebar_tree_membership(mut self, value: Vec<String>) -> Self {
|
||||
self.sidebar_tree_membership = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn body_ref(mut self, value: Option<String>) -> Self {
|
||||
self.body_ref = value;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Head ──
|
||||
|
||||
pub fn title(mut self, value: impl Into<String>) -> Self {
|
||||
@@ -249,8 +284,40 @@ impl PageAggregateBuilder {
|
||||
|
||||
/// 消费 builder 并产出 `PageAggregate`。
|
||||
pub fn build(self) -> PageAggregate {
|
||||
let 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,
|
||||
};
|
||||
let layout_options = to_value(&page_options).expect("PageOptions 序列化不应失败");
|
||||
let updated_at_text = self.updated_at.as_str().map(ToOwned::to_owned);
|
||||
let page_id = self.document_id.clone();
|
||||
PageAggregate {
|
||||
schema: "mnote.page_aggregate.v1".to_string(),
|
||||
projection_version: PageAggregate::VERSION,
|
||||
source: self.source,
|
||||
page_id,
|
||||
parent_id: self.parent_id,
|
||||
title: self.title.clone(),
|
||||
path: if self.path.is_empty() {
|
||||
vec![self.document_id.clone()]
|
||||
} else {
|
||||
self.path
|
||||
},
|
||||
sidebar_tree_membership: self.sidebar_tree_membership,
|
||||
body_ref: self.body_ref,
|
||||
layout_options,
|
||||
updated_at: updated_at_text,
|
||||
identity: PageIdentity {
|
||||
document_id: self.document_id,
|
||||
workspace_id: self.workspace_id,
|
||||
@@ -265,21 +332,7 @@ impl PageAggregateBuilder {
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
page_options,
|
||||
},
|
||||
body: PageBody {
|
||||
content: self.content,
|
||||
|
||||
@@ -42,8 +42,8 @@ pub async fn next_ai_agent_run(
|
||||
trace_id: context.trace.trace_id,
|
||||
target: format!("{}/bridge", state.config().hermes_base_path),
|
||||
notes: vec![
|
||||
"当前保留 Next route 兼容边界,后续用于把 /api/ai-agent/run 收口到 Rust Web 层。",
|
||||
"此占位实现不复制业务裁决,只声明桥接目标与迁移方向。",
|
||||
"/api/ai-agent/run 是 legacy compat endpoint,canonical route 是 /api/hermes/bridge。",
|
||||
"结构化写入必须通过 Hermes/Rust bridge 再落到 page/tree/edge command。",
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,6 +46,25 @@ pub struct DocumentSaveRequest {
|
||||
pub block_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentTitleRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub title: String,
|
||||
pub command_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentOptionsRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub options: Value,
|
||||
pub command_name: Option<String>,
|
||||
}
|
||||
|
||||
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
|
||||
@@ -484,7 +503,7 @@ pub async fn save(
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "documents.save".into(),
|
||||
name: "page.body.save".into(),
|
||||
command_id: format!("document_save_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
@@ -518,6 +537,73 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let mut result = execute_runtime_command_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert("executedCommand".into(), json!("page.body.save"));
|
||||
map.insert("canonicalCommand".into(), json!("page.body.save"));
|
||||
map.insert("compatRoute".into(), json!("/api/documents/save"));
|
||||
}
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn title(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentTitleRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let title = body.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("title_required", "缺少有效页面标题")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "page.head.updateTitle".into(),
|
||||
command_id: format!("page_title_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"title": title,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page title update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.head.updateTitle".into())],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
@@ -525,7 +611,94 @@ pub async fn save(
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"meta": {
|
||||
"commandName": "page.head.updateTitle",
|
||||
"canonicalCommand": "page.head.updateTitle",
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn options(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentOptionsRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "page.layout.updateOptions".into(),
|
||||
command_id: format!("page_options_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"options": body.options,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page layout update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.layout.updateOptions".into())],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"meta": {
|
||||
"commandName": "page.layout.updateOptions",
|
||||
"canonicalCommand": "page.layout.updateOptions",
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -597,6 +770,14 @@ mod tests {
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateTitle": {
|
||||
"ok": true,
|
||||
"title": "服务端页面(改名)"
|
||||
},
|
||||
"documents:updateOptions": {
|
||||
"ok": true,
|
||||
"show_toc": false
|
||||
},
|
||||
"documents:updateContent": {
|
||||
"ok": true,
|
||||
"updated_at": "2026-04-18T09:45:00Z",
|
||||
@@ -691,7 +872,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_save_route_executes_documents_save_command() {
|
||||
async fn documents_save_route_executes_page_body_save_command() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -728,5 +909,71 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["revision"], 8);
|
||||
assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
|
||||
assert_eq!(payload["result"]["executedCommand"], "page.body.save");
|
||||
assert_eq!(payload["result"]["canonicalCommand"], "page.body.save");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_title_route_executes_page_head_update_title() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/title")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"title": "服务端页面(改名)",
|
||||
"commandName": "page.head.updateTitle"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["meta"]["commandName"], "page.head.updateTitle");
|
||||
assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_options_route_executes_page_layout_update_options() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/options")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"options": {
|
||||
"showToc": false
|
||||
},
|
||||
"commandName": "page.layout.updateOptions"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["meta"]["commandName"], "page.layout.updateOptions");
|
||||
assert_eq!(payload["meta"]["canonicalCommand"], "page.layout.updateOptions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +39,48 @@ pub async fn health(
|
||||
|
||||
pub async fn bridge_runtime(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(runtime_input): Json<RuntimeInput>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let session_id = format!("hermes_{}", context.trace.request_id);
|
||||
let runtime_input = match serde_json::from_value::<RuntimeInput>(payload.clone()) {
|
||||
Ok(runtime_input) => runtime_input,
|
||||
Err(_) => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_ai_bridge_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"bridge": "hermes_session",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"structuredWrite": {
|
||||
"owner": "rust-web-hermes",
|
||||
"allowedCommands": [
|
||||
"page.body.save",
|
||||
"tree.node.create",
|
||||
"kernel.edge.attach"
|
||||
]
|
||||
},
|
||||
"compatPayload": payload,
|
||||
})),
|
||||
));
|
||||
}
|
||||
};
|
||||
let payload = if runtime_input_requests_result(&runtime_input) {
|
||||
match execute_runtime_query(runtime_input) {
|
||||
Ok(result) => json!({
|
||||
"ok": true,
|
||||
"bridge": "hermes_runtime_result",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(),
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"result": result,
|
||||
}),
|
||||
Err(error) => {
|
||||
@@ -67,9 +99,12 @@ pub async fn bridge_runtime(
|
||||
json!({
|
||||
"ok": success.ok,
|
||||
"bridge": "hermes_runtime_plan",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(),
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"plan": success.plan,
|
||||
})
|
||||
}
|
||||
@@ -87,14 +122,18 @@ pub async fn bridge_runtime(
|
||||
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
|
||||
}
|
||||
|
||||
fn ai_bridge_contract() -> Value {
|
||||
fn ai_bridge_contract(session_id: &str) -> Value {
|
||||
json!({
|
||||
"schema": "mnote.ai_bridge.v1",
|
||||
"owner": "mnote-web",
|
||||
"bridge": "hermes",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{session_id}"),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"sessionOwner": "rust-web-hermes",
|
||||
"toolEventOwner": "rust-web-hermes",
|
||||
"clientActionOwner": "rust-web-hermes"
|
||||
"clientActionOwner": "rust-web-hermes",
|
||||
"structuredWriteOwner": "rust-web-hermes"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,5 +251,39 @@ mod tests {
|
||||
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert!(payload["eventStreamEndpoint"].as_str().unwrap_or_default().contains("/api/hermes/events/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/bridge")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"stream": true,
|
||||
"scope": "document",
|
||||
"messages": [{"role": "user", "content": "生成摘要"}],
|
||||
"context": {"documentId": "doc_1"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["bridge"], "hermes_session");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert_eq!(payload["contract"]["structuredWriteOwner"], "rust-web-hermes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,15 @@ pub async fn mindmap_object_shell(
|
||||
"mindmapId": mindmap_id,
|
||||
"projection": {
|
||||
"schema": "mnote.mindmap_projection.v1",
|
||||
"source": "rust-web-object-shell"
|
||||
"source": "rust-kernel",
|
||||
"owner": "rust-kernel",
|
||||
"queryName": "mindmap.projection.get"
|
||||
},
|
||||
"island": {
|
||||
"kind": "react_mindmap_runtime",
|
||||
"mountId": "mnote-mindmap-island",
|
||||
"legacyCompat": "next-app-router"
|
||||
"runtimeRole": "renderer_adapter",
|
||||
"commandName": "mindmap.command.apply"
|
||||
},
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id
|
||||
@@ -145,6 +148,8 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("mnote.mindmap_shell.v1"));
|
||||
assert!(html.contains("data-react-island=\"mindmap_runtime\""));
|
||||
assert!(html.contains("rust-web-object-shell"));
|
||||
assert!(html.contains("mindmap.projection.get"));
|
||||
assert!(html.contains("mindmap.command.apply"));
|
||||
assert!(!html.contains("next-app-router"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
.route("/api/documents/options", post(documents::options))
|
||||
.route("/api/documents/save", post(documents::save))
|
||||
.route(
|
||||
"/api/documents/runtime/transform",
|
||||
|
||||
@@ -2,7 +2,8 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, resolve_effective_workspace_id,
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||
use crate::ssr::pages::search::SearchPage;
|
||||
@@ -68,18 +69,30 @@ pub async fn shell(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
|
||||
let initial_results = load_search_results(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
search_query,
|
||||
None,
|
||||
20,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| empty_search_projection());
|
||||
let contract = json!({
|
||||
"schema": "mnote.search_shell.v1",
|
||||
"owner": "mnote-web",
|
||||
"projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"shell": "search",
|
||||
"workspaceId": workspace_id,
|
||||
"query": search_query,
|
||||
"initialResults": {
|
||||
"queryName": "search.documents",
|
||||
"results": []
|
||||
"queryName": "search.documents.query",
|
||||
"projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"results": initial_results.get("results").cloned().unwrap_or_else(|| Value::Array(vec![]))
|
||||
},
|
||||
"island": {
|
||||
"kind": "react_search_palette",
|
||||
"kind": "search_interaction_island",
|
||||
"mountId": "mnote-search-island",
|
||||
"runtime": "SearchPaletteHost"
|
||||
},
|
||||
@@ -92,6 +105,7 @@ pub async fn shell(
|
||||
workspace_id={workspace_id.to_string()}
|
||||
search_query={search_query.to_string()}
|
||||
sidebar_tree_html={sidebar_tree_html}
|
||||
initial_results_html={render_initial_results_html(initial_results.get("results").and_then(Value::as_array))}
|
||||
/>
|
||||
});
|
||||
let html = format!(
|
||||
@@ -122,7 +136,7 @@ pub async fn shell(
|
||||
}
|
||||
|
||||
pub async fn documents(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<SearchDocumentsRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
@@ -137,49 +151,16 @@ pub async fn documents(
|
||||
None
|
||||
};
|
||||
|
||||
let result = if normalized_query.is_empty() {
|
||||
json!({
|
||||
"enqueueAssetIds": [],
|
||||
"results": [],
|
||||
})
|
||||
} else {
|
||||
execute_runtime_query_against_data(
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "search.documents".into(),
|
||||
payload: json!({
|
||||
"query": normalized_query,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"pageId": page_id,
|
||||
"limit": body.limit.unwrap_or(30),
|
||||
"titleOnly": filters.title_only.unwrap_or(false),
|
||||
"exact": filters.exact.unwrap_or(false),
|
||||
"includeOcr": filters.include_ocr.unwrap_or(false),
|
||||
"timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
|
||||
"timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
|
||||
"customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
|
||||
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
|
||||
}),
|
||||
},
|
||||
json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "doc_1",
|
||||
"workspaceId": effective_workspace_id,
|
||||
"title": "Rust Web 搜索结果",
|
||||
"rawText": "mnote-web search documents transport",
|
||||
"createdAt": "2026-04-28T00:00:00Z",
|
||||
"updatedAt": "2026-04-28T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"mindmaps": [],
|
||||
"tables": [],
|
||||
"tableRows": [],
|
||||
"assets": []
|
||||
}),
|
||||
)?
|
||||
};
|
||||
let result = load_search_results_with_filters(
|
||||
state.config(),
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
&normalized_query,
|
||||
page_id,
|
||||
body.limit.unwrap_or(30),
|
||||
filters,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
@@ -188,10 +169,12 @@ pub async fn documents(
|
||||
headers,
|
||||
Json(json!({
|
||||
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"recent": [],
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
"queryName": "search.documents",
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"queryName": "search.documents.query",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
},
|
||||
@@ -199,10 +182,138 @@ pub async fn documents(
|
||||
))
|
||||
}
|
||||
|
||||
async fn load_search_results(
|
||||
config: &crate::app::AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
query: &str,
|
||||
page_id: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Value, WebError> {
|
||||
load_search_results_with_filters(
|
||||
config,
|
||||
context,
|
||||
workspace_id,
|
||||
query,
|
||||
page_id,
|
||||
limit,
|
||||
SearchDocumentsFilters::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_search_results_with_filters(
|
||||
config: &crate::app::AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
query: &str,
|
||||
page_id: Option<String>,
|
||||
limit: u32,
|
||||
filters: SearchDocumentsFilters,
|
||||
) -> Result<Value, WebError> {
|
||||
if query.trim().is_empty() {
|
||||
return Ok(empty_search_projection());
|
||||
}
|
||||
let runtime_query = RuntimeQueryEnvelopeWire {
|
||||
name: "search.documents.query".into(),
|
||||
payload: json!({
|
||||
"query": query,
|
||||
"workspaceId": workspace_id,
|
||||
"pageId": page_id,
|
||||
"limit": limit,
|
||||
"titleOnly": filters.title_only.unwrap_or(false),
|
||||
"exact": filters.exact.unwrap_or(false),
|
||||
"includeOcr": filters.include_ocr.unwrap_or(false),
|
||||
"timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
|
||||
"timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
|
||||
"customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
|
||||
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
|
||||
}),
|
||||
};
|
||||
match execute_runtime_query_via_convex(config, context, Some(workspace_id), runtime_query.clone()).await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => execute_runtime_query_against_data(
|
||||
context,
|
||||
Some(workspace_id),
|
||||
runtime_query,
|
||||
fallback_search_dataset(workspace_id),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_search_projection() -> Value {
|
||||
json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
"results": [],
|
||||
})
|
||||
}
|
||||
|
||||
fn fallback_search_dataset(workspace_id: &str) -> Value {
|
||||
json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "doc_1",
|
||||
"workspaceId": workspace_id,
|
||||
"title": "Rust Web 搜索结果",
|
||||
"rawText": "mnote-web search documents transport",
|
||||
"createdAt": "2026-04-28T00:00:00Z",
|
||||
"updatedAt": "2026-04-28T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"mindmaps": [],
|
||||
"tables": [],
|
||||
"tableRows": [],
|
||||
"assets": []
|
||||
})
|
||||
}
|
||||
|
||||
fn render_initial_results_html(results: Option<&Vec<Value>>) -> String {
|
||||
let Some(results) = results else {
|
||||
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
|
||||
};
|
||||
if results.is_empty() {
|
||||
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
|
||||
}
|
||||
let items = results
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let title = item
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("无标题");
|
||||
let snippet = item
|
||||
.get("snippet")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let href = item
|
||||
.get("publicPath")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("#");
|
||||
format!(
|
||||
r#"<a class="search-result" data-search-result-owner="rust-kernel" href="{}"><strong>{}</strong><span>{}</span></a>"#,
|
||||
escape_html(href),
|
||||
escape_html(title),
|
||||
escape_html(snippet)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
format!(r#"<div class="search-result-list">{items}</div>"#)
|
||||
}
|
||||
|
||||
fn escape_script_json(value: &str) -> String {
|
||||
value.replace("</script", "<\\/script")
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn stamp_search_headers(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
@@ -275,8 +386,10 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("data-mnote-shell=\"search\""));
|
||||
assert!(html.contains("mnote.search_shell.v1"));
|
||||
assert!(html.contains("react_search_palette"));
|
||||
assert!(html.contains("search.documents"));
|
||||
assert!(html.contains("search_interaction_island"));
|
||||
assert!(html.contains("search.documents.query"));
|
||||
assert!(html.contains("search-result"));
|
||||
assert!(html.contains("Rust Web 搜索结果"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -327,6 +440,8 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["meta"]["owner"], "mnote-web");
|
||||
assert_eq!(payload["meta"]["queryName"], "search.documents");
|
||||
assert_eq!(payload["meta"]["queryName"], "search.documents.query");
|
||||
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
|
||||
assert_eq!(payload["projectionOwner"], "rust-kernel");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,19 @@ struct StreamPollState {
|
||||
}
|
||||
|
||||
fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
let event_id = payload
|
||||
.get("revision")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| value.as_u64().map(|number| number.to_string()))
|
||||
})
|
||||
.or_else(|| payload.get("cursor").and_then(Value::as_str).map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "0".into());
|
||||
Event::default()
|
||||
.event(event_name)
|
||||
.id(event_id)
|
||||
.json_data(payload)
|
||||
.expect("SSE 事件必须可序列化")
|
||||
}
|
||||
@@ -245,4 +256,25 @@ mod tests {
|
||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||
assert!(text.contains("\"kind\":\"snapshot\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_events_route_includes_event_id_and_revision() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=0")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("id: "));
|
||||
assert!(text.contains("\"revision\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +477,7 @@ pub fn build_stream_delta_payload(
|
||||
let scope = resolve_stream_scope(query);
|
||||
json!({
|
||||
"kind": "delta",
|
||||
"revision": cursor.clone().unwrap_or_else(|| "0".into()),
|
||||
"stream": scope.as_str(),
|
||||
"projection": scope.projection(),
|
||||
"requestId": context.trace.request_id,
|
||||
@@ -570,6 +571,7 @@ pub async fn load_stream_snapshot(
|
||||
|
||||
Ok(json!({
|
||||
"kind": "snapshot",
|
||||
"revision": cursor.clone().unwrap_or_else(|| "0".into()),
|
||||
"scope": scope.as_str(),
|
||||
"stream": scope.as_str(),
|
||||
"projection": scope.projection(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
||||
};
|
||||
@@ -24,9 +25,10 @@ use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use std::path::{Component, Path as FsPath, PathBuf};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
@@ -52,7 +54,7 @@ pub async fn document_page_shell(
|
||||
query.workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let title = aggregate.head_title();
|
||||
let title = aggregate.head.title.as_str();
|
||||
let workspace_id = aggregate.identity.workspace_id.clone();
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let mut workspace_projection = load_workspace_shell_projection(
|
||||
@@ -86,6 +88,7 @@ pub async fn document_page_shell(
|
||||
<DocumentPage
|
||||
title={title.to_string()}
|
||||
document_id={document_id.clone()}
|
||||
workspace_id={workspace_id.clone()}
|
||||
sidebar_tree_html={sidebar_tree_html}
|
||||
workspace_name={workspace_name}
|
||||
workspace_sidebar_html={workspace_sidebar_html}
|
||||
@@ -105,6 +108,7 @@ pub async fn document_page_shell(
|
||||
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(title),
|
||||
@@ -113,6 +117,7 @@ pub async fn document_page_shell(
|
||||
body_content,
|
||||
escape_script_json(&snapshot_json),
|
||||
escape_script_json(&bootstrap_json),
|
||||
render_document_title_controller_script(),
|
||||
render_editor_island_adapter_script(),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
@@ -128,6 +133,7 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
|
||||
"workspaceId": aggregate.identity.workspace_id,
|
||||
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
|
||||
"saveEndpoint": "/api/documents/save",
|
||||
"titleEndpoint": "/api/documents/title",
|
||||
"editorHostKind": "leptos_tiptap_island",
|
||||
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
|
||||
"requestId": context.trace.request_id,
|
||||
@@ -136,6 +142,123 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn render_document_title_controller_script() -> &'static str {
|
||||
r#"<script>
|
||||
(() => {
|
||||
const CONTRACT = 'mnote.document_title_controller.v1';
|
||||
const input = document.querySelector('[data-page-title-input="true"]');
|
||||
if (!(input instanceof HTMLTextAreaElement)) return;
|
||||
|
||||
input.setAttribute('data-title-controller', CONTRACT);
|
||||
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
|
||||
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
|
||||
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
|
||||
let lastSavedTitle = input.value.trim() || '无标题';
|
||||
let saving = false;
|
||||
|
||||
const cssEscape = (value) => {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
const autosize = () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
|
||||
};
|
||||
|
||||
const setStatus = (status, message) => {
|
||||
input.setAttribute('data-title-save-status', status);
|
||||
const shell = input.closest('.document-shell');
|
||||
if (shell instanceof HTMLElement) shell.setAttribute('data-title-save-status', status);
|
||||
if (message) input.setAttribute('data-title-save-error', message);
|
||||
else input.removeAttribute('data-title-save-error');
|
||||
};
|
||||
|
||||
const setText = (selector, title) => {
|
||||
document.querySelectorAll(selector).forEach((node) => {
|
||||
if (node instanceof HTMLElement) node.textContent = title;
|
||||
});
|
||||
};
|
||||
|
||||
const updateVisibleTitle = (title) => {
|
||||
document.title = title;
|
||||
setText('[data-page-title-current]', title);
|
||||
const current = document.querySelector('.wolai-breadcrumb-current');
|
||||
if (current instanceof HTMLElement) {
|
||||
let titleNode = current.querySelector('[data-page-title-current]');
|
||||
if (!(titleNode instanceof HTMLElement)) {
|
||||
titleNode = document.createElement('span');
|
||||
titleNode.setAttribute('data-page-title-current', 'true');
|
||||
current.appendChild(titleNode);
|
||||
}
|
||||
titleNode.textContent = title;
|
||||
}
|
||||
if (!documentId) return;
|
||||
const escapedId = cssEscape(documentId);
|
||||
setText(`[data-node-id="${escapedId}"] .tree-link-title`, title);
|
||||
setText(`[data-document-id="${escapedId}"] .tree-link-title`, title);
|
||||
setText(`[data-doc-id="${escapedId}"] .tree-link-title`, title);
|
||||
setText(`[data-node-id="${escapedId}"] .wolai-row-title`, title);
|
||||
setText(`a[href="/documents/${escapedId}"] .wolai-row-title`, title);
|
||||
setText(`a[href^="/documents/${escapedId}?"] .wolai-row-title`, title);
|
||||
};
|
||||
|
||||
const saveTitle = async () => {
|
||||
const title = input.value.trim() || '无标题';
|
||||
autosize();
|
||||
if (!documentId || saving || title === lastSavedTitle) {
|
||||
updateVisibleTitle(title);
|
||||
setStatus('saved');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
setStatus('saving');
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
workspaceId: workspaceId || null,
|
||||
title,
|
||||
commandName: 'page.head.updateTitle',
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
|
||||
}
|
||||
lastSavedTitle = title;
|
||||
updateVisibleTitle(title);
|
||||
setStatus('saved');
|
||||
window.dispatchEvent(new CustomEvent('tree:title-updated', {
|
||||
detail: { documentId, workspaceId: workspaceId || null, title, payload },
|
||||
}));
|
||||
} catch (error) {
|
||||
setStatus('error', error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
autosize();
|
||||
setStatus(input.value.trim() === lastSavedTitle ? 'saved' : 'dirty');
|
||||
});
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => { void saveTitle(); });
|
||||
autosize();
|
||||
updateVisibleTitle(lastSavedTitle);
|
||||
setStatus('saved');
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
|
||||
fn render_editor_island_adapter_script() -> &'static str {
|
||||
r#"<script type="module">
|
||||
(() => {
|
||||
@@ -441,6 +564,7 @@ pub async fn page_aggregate(
|
||||
query.workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let projection_owner = aggregate.source_label();
|
||||
let mut response = (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
@@ -454,6 +578,11 @@ pub async fn page_aggregate(
|
||||
)
|
||||
.into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "page-aggregate");
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
|
||||
if let Ok(value) = HeaderValue::from_str(projection_owner) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -482,145 +611,24 @@ async fn build_page_aggregate_snapshot(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let conflict_detection_key = content
|
||||
.get("conflictDetectionKey")
|
||||
.or_else(|| content.get("conflict_detection_key"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let page_subtree = content
|
||||
.get("pageSubtree")
|
||||
.or_else(|| content.get("page_subtree"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let todo_total = meta
|
||||
.get("todo_total")
|
||||
.or_else(|| meta.get("todo_total_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let todo_done = meta
|
||||
.get("todo_done")
|
||||
.or_else(|| meta.get("todo_done_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let projection = execute_runtime_query_against_data(
|
||||
context,
|
||||
workspace_id,
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "page.aggregate.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
},
|
||||
json!({
|
||||
"meta": meta,
|
||||
"content": content,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(PageAggregate::builder()
|
||||
// identity
|
||||
.document_id(
|
||||
meta.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(document_id),
|
||||
)
|
||||
.workspace_id(
|
||||
meta.get("workspace_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default"),
|
||||
)
|
||||
// head
|
||||
.title(
|
||||
meta.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("无标题"),
|
||||
)
|
||||
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
|
||||
.read_only(
|
||||
meta.get("can_edit")
|
||||
.and_then(Value::as_bool)
|
||||
.map(|can_edit| !can_edit)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.disable_download(
|
||||
meta.get("disable_download")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.disable_copy(
|
||||
meta.get("disable_copy")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
// layout
|
||||
.wide_layout(
|
||||
meta.get("wide_layout")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.small_text(
|
||||
meta.get("use_small_text")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_heading_numbers(
|
||||
meta.get("show_heading_numbers")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
)
|
||||
.show_toc(
|
||||
meta.get("show_toc")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_structure(
|
||||
meta.get("show_structure")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.protect_editing(
|
||||
meta.get("protect_editing")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_word_count(
|
||||
meta.get("show_word_count")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
)
|
||||
.collapse_backlinks(
|
||||
meta.get("collapse_backlinks")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.page_font(
|
||||
meta.get("page_font")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default"),
|
||||
)
|
||||
.layout_density(
|
||||
meta.get("layout_density")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("normal"),
|
||||
)
|
||||
.hide_child_pages(
|
||||
meta.get("hide_child_pages")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.show_block_ref_count(
|
||||
meta.get("show_block_ref_count")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.embed_default_block_id(
|
||||
meta.get("embed_default_block_id")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
)
|
||||
// body
|
||||
.content(content.get("content").cloned().unwrap_or(Value::Null))
|
||||
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
|
||||
.conflict_detection_key(conflict_detection_key)
|
||||
// tree
|
||||
.page_subtree(page_subtree)
|
||||
// stats
|
||||
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
|
||||
.character_count(
|
||||
meta.get("character_count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
|
||||
.todo_total(todo_total)
|
||||
.todo_done(todo_done)
|
||||
.build())
|
||||
serde_json::from_value::<PageAggregate>(projection)
|
||||
.map_err(|error| WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}")))
|
||||
}
|
||||
|
||||
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
||||
@@ -929,6 +937,15 @@ mod tests {
|
||||
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
|
||||
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
|
||||
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
||||
assert!(html.contains("aria-label=\"页面标题\""));
|
||||
assert!(html.contains("data-page-title-input=\"true\""));
|
||||
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
|
||||
assert!(html.contains("mnote.document_title_controller.v1"));
|
||||
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
|
||||
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
@@ -945,12 +962,21 @@ mod tests {
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-page-aggregate-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-kernel")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(payload["result"]["source"], "KernelProjection");
|
||||
assert_eq!(payload["result"]["projectionVersion"], 1);
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ pub fn DocumentPage(
|
||||
title: String,
|
||||
/// 文档 id
|
||||
document_id: String,
|
||||
/// 工作区 id
|
||||
workspace_id: String,
|
||||
/// 侧栏页面树 HTML(可选)
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
@@ -44,10 +46,27 @@ pub fn DocumentPage(
|
||||
.unwrap_or_else(|| "个人空间".to_string());
|
||||
view! {
|
||||
<PageLayout current_nav="documents" 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()} topbar_title={title.clone()}>
|
||||
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()}>
|
||||
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()} data-workspace-id={workspace_id.clone()}>
|
||||
<header class="document-shell-header">
|
||||
<div class="document-page-icon" aria-hidden="true">"⌂"</div>
|
||||
<h1>{title}</h1>
|
||||
<div class="document-page-icon" aria-hidden="true">
|
||||
<svg class="mnote-symbol mnote-symbol--document" data-icon="home" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M4 10.5 12 3l8 7.5"></path>
|
||||
<path d="M6.5 9.5V21h11V9.5"></path>
|
||||
<path d="M9.5 21v-6h5v6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="document-title-heading">
|
||||
<textarea
|
||||
id="mnote-page-title-input"
|
||||
class="document-title-input"
|
||||
aria-label="页面标题"
|
||||
data-page-title-input="true"
|
||||
data-document-id={document_id.clone()}
|
||||
data-workspace-id={workspace_id.clone()}
|
||||
data-title-endpoint="/api/documents/title"
|
||||
rows="1"
|
||||
>{title.clone()}</textarea>
|
||||
</h1>
|
||||
<div class="document-shell-meta" aria-label="页面元信息">
|
||||
<span><span aria-hidden="true">"◌"</span>{workspace_label}</span>
|
||||
<span><span aria-hidden="true">"▣"</span>"已同步"</span>
|
||||
|
||||
@@ -4,20 +4,63 @@ use leptos::prelude::*;
|
||||
|
||||
const SIDEBAR_TREE_JS: &str = r##"
|
||||
(function(){
|
||||
if (window.__mnoteSidebarTreeRuntimeStarted) return;
|
||||
window.__mnoteSidebarTreeRuntimeStarted = true;
|
||||
|
||||
var PAGE_DRAG_MIME = 'application/x-mnote-page-tree-node';
|
||||
var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids';
|
||||
var mnoteNavigationInFlight = '';
|
||||
var draggingPageNodeId = '';
|
||||
var activePageDropRow = null;
|
||||
var draggingFileTreeRowIds = [];
|
||||
var activeFileTreeDropRow = null;
|
||||
var projectionRefreshTimer = 0;
|
||||
|
||||
function closestAction(target, selector) {
|
||||
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function currentDocumentId() {
|
||||
var match = window.location.pathname.match(/^\/documents\/([^\/]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
function resolveWorkspaceId(trigger) {
|
||||
var direct = (trigger.getAttribute('data-workspace-id') || '').trim();
|
||||
if (direct) return direct;
|
||||
var root = trigger.closest('[data-workspace-id]');
|
||||
return root ? (root.getAttribute('data-workspace-id') || '').trim() : '';
|
||||
if (root) {
|
||||
var value = (root.getAttribute('data-workspace-id') || '').trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
function setCommandPending(trigger, pending) {
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('data-pending', pending ? 'true' : 'false');
|
||||
if ('disabled' in trigger) {
|
||||
if (pending) trigger.setAttribute('disabled', 'disabled');
|
||||
else trigger.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchTreeCommand(trigger, body) {
|
||||
trigger.setAttribute('data-pending', 'true');
|
||||
trigger.setAttribute('disabled', 'disabled');
|
||||
setCommandPending(trigger, true);
|
||||
try {
|
||||
var response = await fetch('/api/tree/commands', {
|
||||
method: 'POST',
|
||||
@@ -28,15 +71,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (!response.ok || !payload || !payload.result) {
|
||||
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: body, result: payload.result } }));
|
||||
return payload.result;
|
||||
} catch (error) {
|
||||
trigger.removeAttribute('disabled');
|
||||
trigger.setAttribute('data-pending', 'false');
|
||||
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
|
||||
setCommandPending(trigger, false);
|
||||
if (trigger instanceof HTMLElement) {
|
||||
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToDocument(nodeId, workspaceId) {
|
||||
if (!nodeId) return;
|
||||
var url = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
|
||||
if (mnoteNavigationInFlight === url) return;
|
||||
mnoteNavigationInFlight = url;
|
||||
document.documentElement.setAttribute('data-mnote-navigation-pending', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-navigation-target', nodeId);
|
||||
if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') {
|
||||
window.__mnoteTreeLiveEventSource.close();
|
||||
}
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
async function createPage(trigger, parentId) {
|
||||
var workspaceId = resolveWorkspaceId(trigger);
|
||||
var effectiveParentId = (parentId || trigger.getAttribute('data-parent-id') || '').trim();
|
||||
@@ -48,7 +106,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
title: '新页面'
|
||||
});
|
||||
var nextWorkspaceId = result.workspaceId || workspaceId;
|
||||
window.location.href = '/documents/' + encodeURIComponent(result.documentId) + '?workspaceId=' + encodeURIComponent(nextWorkspaceId);
|
||||
navigateToDocument(result.documentId, nextWorkspaceId);
|
||||
}
|
||||
|
||||
function switchSidebarTreeTab(trigger) {
|
||||
@@ -69,6 +127,192 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function updateTitleEverywhere(documentId, title) {
|
||||
if (!documentId) return;
|
||||
var selectors = [
|
||||
'[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title',
|
||||
'[data-document-id="' + cssEscape(documentId) + '"] .tree-link-title',
|
||||
'[data-doc-id="' + cssEscape(documentId) + '"] .tree-link-title',
|
||||
'[data-node-id="' + cssEscape(documentId) + '"] .wolai-row-title'
|
||||
];
|
||||
selectors.forEach(function(selector) {
|
||||
document.querySelectorAll(selector).forEach(function(node) {
|
||||
if (node instanceof HTMLElement) node.textContent = title;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readProjection(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
if (value.result && typeof value.result === 'object') return value.result;
|
||||
if (value.snapshot && value.snapshot.tree) return value.snapshot.tree;
|
||||
if (value.data && value.data.tree) return value.data.tree;
|
||||
if (value.tree && typeof value.tree === 'object') return value.tree;
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectionItems(projection) {
|
||||
var resolved = readProjection(projection);
|
||||
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
|
||||
}
|
||||
|
||||
function nodeIdOf(item) {
|
||||
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
|
||||
}
|
||||
|
||||
function rowIdOf(item) {
|
||||
return String(item && (item.rowId || item.nodeId || item.id) || '').trim();
|
||||
}
|
||||
|
||||
function parentIdOf(item) {
|
||||
return String(item && (item.parentNodeId || item.parentId || '') || '').trim();
|
||||
}
|
||||
|
||||
function titleOf(item) {
|
||||
return String(item && item.title || '无标题').trim() || '无标题';
|
||||
}
|
||||
|
||||
function groupRowsByParent(rows) {
|
||||
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
|
||||
var grouped = new Map();
|
||||
rows.forEach(function(item) {
|
||||
var parentId = parentIdOf(item);
|
||||
if (!ids.has(parentId)) parentId = '';
|
||||
if (!grouped.has(parentId)) grouped.set(parentId, []);
|
||||
grouped.get(parentId).push(item);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function renderPageRows(parentId, grouped, activeId) {
|
||||
return (grouped.get(parentId) || []).map(function(item) {
|
||||
var nodeId = nodeIdOf(item);
|
||||
var title = titleOf(item);
|
||||
var depth = Number(item.depth || 0);
|
||||
var parent = parentIdOf(item);
|
||||
var children = grouped.get(nodeId) || [];
|
||||
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
||||
var expanded = expandable && item.expandedByDefault !== false;
|
||||
var toggle = expandable
|
||||
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
|
||||
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
||||
var childHtml = expandable && expanded
|
||||
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '" data-focused="false" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="' + escapeHtml(nodeId) + '" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderPageProjection(projection) {
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
if (!tree) return false;
|
||||
var rows = projectionItems(projection).filter(function(item) {
|
||||
return String(item.rowKind || 'document') === 'document';
|
||||
});
|
||||
var activeId = currentDocumentId();
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId) : '<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>') + '</ul>';
|
||||
return true;
|
||||
}
|
||||
|
||||
function fileDocumentId(item) {
|
||||
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
||||
if (meta.documentId) return String(meta.documentId).trim();
|
||||
if (item && item.documentId) return String(item.documentId).trim();
|
||||
if (item && item.rowKind === 'document') return nodeIdOf(item);
|
||||
if (item && item.rowKind === 'index') return nodeIdOf(item).replace(/^index:/, '');
|
||||
return '';
|
||||
}
|
||||
|
||||
function fileAssetId(item) {
|
||||
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
||||
if (meta.assetId) return String(meta.assetId).trim();
|
||||
if (item && item.assetId) return String(item.assetId).trim();
|
||||
if (item && item.rowKind === 'asset') return nodeIdOf(item).replace(/^asset:/, '');
|
||||
return '';
|
||||
}
|
||||
|
||||
function iconKindOf(item) {
|
||||
return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file';
|
||||
}
|
||||
|
||||
function renderFileRows(parentId, grouped, activeId) {
|
||||
return (grouped.get(parentId) || []).map(function(item) {
|
||||
var nodeId = nodeIdOf(item);
|
||||
var rowId = rowIdOf(item);
|
||||
var rowKind = String(item.rowKind || 'document');
|
||||
var title = titleOf(item);
|
||||
var depth = Number(item.depth || 0);
|
||||
var parent = parentIdOf(item);
|
||||
var documentId = fileDocumentId(item);
|
||||
var assetId = fileAssetId(item);
|
||||
var children = grouped.get(nodeId) || [];
|
||||
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
||||
var expanded = expandable && item.expandedByDefault !== false;
|
||||
var selected = rowId === 'doc:' + activeId || rowId === 'index:' + activeId || documentId === activeId;
|
||||
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
|
||||
var toggle = expandable
|
||||
? '<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="' + escapeHtml(rowId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
|
||||
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
||||
var createAction = rowKind === 'document'
|
||||
? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>'
|
||||
: '';
|
||||
var childHtml = expandable && expanded
|
||||
? '<ul class="tree-children">' + renderFileRows(nodeId, grouped, activeId) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderFileProjection(projection) {
|
||||
var tree = document.getElementById('sidebar-file-tree-root');
|
||||
if (!tree) return false;
|
||||
var rows = projectionItems(projection);
|
||||
var activeId = currentDocumentId();
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId) : '<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>') + '</ul>';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchProjection(path, workspaceId) {
|
||||
var url = new URL(path, window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId || 'default');
|
||||
url.searchParams.set('depth', '99');
|
||||
var response = await fetch(url.toString(), { cache: 'no-store' });
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || !payload.result) throw new Error('tree_projection_failed_' + response.status);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function scheduleProjectionRefresh(workspaceId) {
|
||||
if (projectionRefreshTimer) window.clearTimeout(projectionRefreshTimer);
|
||||
projectionRefreshTimer = window.setTimeout(function() {
|
||||
projectionRefreshTimer = 0;
|
||||
var resolvedWorkspaceId = workspaceId || resolveWorkspaceId(document.body);
|
||||
Promise.all([
|
||||
fetchProjection('/api/tree/projections/sidebar', resolvedWorkspaceId).then(renderPageProjection),
|
||||
fetchProjection('/api/tree/projections/file', resolvedWorkspaceId).then(renderFileProjection)
|
||||
]).then(function() {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'true');
|
||||
}).catch(function(error) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-apply-error', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function toggleChildren(row, button) {
|
||||
var li = row && row.parentElement;
|
||||
var children = li ? li.querySelector(':scope > .tree-children') : null;
|
||||
if (!children) return;
|
||||
children.classList.toggle('tree-children--collapsed');
|
||||
var collapsed = children.classList.contains('tree-children--collapsed');
|
||||
row.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
if (button) button.textContent = collapsed ? '▸' : '▾';
|
||||
}
|
||||
|
||||
function dispatchSidebarEvent(name, detail) {
|
||||
document.documentElement.setAttribute('data-mnote-last-tree-action', name);
|
||||
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]');
|
||||
if (tabTrigger) {
|
||||
@@ -84,6 +328,43 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileTree && fileTree.contains(e.target)) {
|
||||
var fileBtn = closestAction(e.target, '[data-rust-action]');
|
||||
var fileRow = closestAction(e.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
if (!fileRow) return;
|
||||
var fileAction = fileBtn ? fileBtn.getAttribute('data-rust-action') : 'open';
|
||||
var rowId = fileRow.getAttribute('data-row-id') || '';
|
||||
var rowKind = fileRow.getAttribute('data-row-kind') || '';
|
||||
var documentId = fileRow.getAttribute('data-document-id') || fileRow.getAttribute('data-doc-id') || '';
|
||||
var assetId = fileRow.getAttribute('data-asset-id') || '';
|
||||
if (fileAction === 'toggle') {
|
||||
e.preventDefault();
|
||||
toggleChildren(fileRow, fileBtn);
|
||||
return;
|
||||
}
|
||||
if (fileAction === 'create') {
|
||||
e.preventDefault();
|
||||
void createPage(fileBtn || fileRow, documentId || fileRow.getAttribute('data-node-id'));
|
||||
return;
|
||||
}
|
||||
if (fileAction === 'menu') {
|
||||
e.preventDefault();
|
||||
dispatchSidebarEvent('tree.filetree.context-menu', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
fileTree.querySelectorAll('.tree-row[data-selected="true"]').forEach(function(row) {
|
||||
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'false');
|
||||
});
|
||||
fileRow.setAttribute('data-selected', 'true');
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
if ((rowKind === 'document' || rowKind === 'index') && documentId) {
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
if (!tree || !tree.contains(e.target)) return;
|
||||
var btn = closestAction(e.target, '[data-rust-action]');
|
||||
@@ -94,18 +375,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
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 ? '▸' : '▾';
|
||||
}
|
||||
toggleChildren(row, btn);
|
||||
e.preventDefault();
|
||||
} else if (action === 'open') {
|
||||
var workspaceId = resolveWorkspaceId(btn);
|
||||
window.location.href = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
|
||||
navigateToDocument(nodeId, workspaceId);
|
||||
e.preventDefault();
|
||||
} else if (action === 'create') {
|
||||
e.preventDefault();
|
||||
@@ -119,23 +393,323 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
workspaceId: resolveWorkspaceId(btn),
|
||||
documentId: nodeId,
|
||||
title: title.trim()
|
||||
}).then(function(){ window.location.reload(); });
|
||||
}).then(function(){
|
||||
updateTitleEverywhere(nodeId, title.trim());
|
||||
scheduleProjectionRefresh(resolveWorkspaceId(btn));
|
||||
});
|
||||
}
|
||||
} else if (action === 'menu') {
|
||||
e.preventDefault();
|
||||
dispatchSidebarEvent('tree.page.context-menu', { documentId: nodeId, target: { documentId: nodeId } });
|
||||
}
|
||||
});
|
||||
|
||||
function readPageDragNodeId(event) {
|
||||
var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : '';
|
||||
return (fromTransfer || draggingPageNodeId || '').trim();
|
||||
}
|
||||
|
||||
function clearPageDropFeedback() {
|
||||
if (activePageDropRow instanceof HTMLElement) {
|
||||
activePageDropRow.setAttribute('data-drop-feedback', 'false');
|
||||
}
|
||||
activePageDropRow = null;
|
||||
}
|
||||
|
||||
function canDropPage(sourceNodeId, targetRow) {
|
||||
if (!sourceNodeId || !(targetRow instanceof HTMLElement)) return false;
|
||||
var targetNodeId = targetRow.getAttribute('data-node-id') || '';
|
||||
if (!targetNodeId || targetNodeId === sourceNodeId) return false;
|
||||
var sourceNode = document.querySelector('#sidebar-tree-root .tree-node[data-node-id="' + cssEscape(sourceNodeId) + '"]');
|
||||
return !(sourceNode instanceof HTMLElement && sourceNode.contains(targetRow));
|
||||
}
|
||||
|
||||
function pageDropPosition(event, row) {
|
||||
var rect = row.getBoundingClientRect();
|
||||
var ratio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||
if (ratio < 0.25) return 'before';
|
||||
if (ratio > 0.75) return 'after';
|
||||
return 'inside';
|
||||
}
|
||||
|
||||
function resolvePageMoveTarget(targetRow, position) {
|
||||
var targetNodeId = targetRow.getAttribute('data-node-id') || '';
|
||||
var parentId = targetRow.getAttribute('data-parent-id') || null;
|
||||
if (position === 'inside') {
|
||||
var children = targetRow.parentElement ? targetRow.parentElement.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="page"]') : [];
|
||||
return { parentId: targetNodeId, sortOrder: children.length };
|
||||
}
|
||||
var siblings = Array.from(document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]')).filter(function(row) {
|
||||
return (row.getAttribute('data-parent-id') || '') === (parentId || '');
|
||||
});
|
||||
var index = siblings.indexOf(targetRow);
|
||||
return { parentId: parentId, sortOrder: Math.max(0, index + (position === 'after' ? 1 : 0)) };
|
||||
}
|
||||
|
||||
document.addEventListener('dragstart', function(event) {
|
||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"][draggable="true"]');
|
||||
if (pageRow) {
|
||||
draggingPageNodeId = pageRow.getAttribute('data-node-id') || '';
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(PAGE_DRAG_MIME, draggingPageNodeId);
|
||||
event.dataTransfer.setData('text/plain', draggingPageNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"][draggable="true"]');
|
||||
if (fileRow) {
|
||||
var rowId = fileRow.getAttribute('data-row-id') || '';
|
||||
draggingFileTreeRowIds = rowId ? [rowId] : [];
|
||||
if (event.dataTransfer) {
|
||||
var payload = JSON.stringify({ type: 'mnote-file-tree-dnd', version: 1, rowIds: draggingFileTreeRowIds });
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
|
||||
event.dataTransfer.setData('application/x-mnote-file-tree', payload);
|
||||
event.dataTransfer.setData('text/plain', payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
document.addEventListener('dragover', function(event) {
|
||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"]');
|
||||
var sourceNodeId = readPageDragNodeId(event);
|
||||
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
|
||||
event.preventDefault();
|
||||
clearPageDropFeedback();
|
||||
pageRow.setAttribute('data-drop-feedback', 'true');
|
||||
pageRow.setAttribute('data-drop-position', pageDropPosition(event, pageRow));
|
||||
activePageDropRow = pageRow;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileTree && fileTree.contains(event.target)) {
|
||||
var hasFiles = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], 'Files') >= 0;
|
||||
var hasInternal = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], FILETREE_DRAG_MIME) >= 0;
|
||||
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return;
|
||||
event.preventDefault();
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles || event.altKey ? 'copy' : 'move';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('drop', function(event) {
|
||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"]');
|
||||
var sourceNodeId = readPageDragNodeId(event);
|
||||
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
|
||||
event.preventDefault();
|
||||
var position = pageDropPosition(event, pageRow);
|
||||
var target = resolvePageMoveTarget(pageRow, position);
|
||||
clearPageDropFeedback();
|
||||
draggingPageNodeId = '';
|
||||
void dispatchTreeCommand(pageRow, {
|
||||
action: 'move',
|
||||
workspaceId: resolveWorkspaceId(pageRow),
|
||||
documentId: sourceNodeId,
|
||||
parentId: target.parentId,
|
||||
sortOrder: target.sortOrder
|
||||
}).then(function(){ scheduleProjectionRefresh(resolveWorkspaceId(pageRow)); });
|
||||
return;
|
||||
}
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileTree && fileTree.contains(event.target)) {
|
||||
var targetRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
var files = event.dataTransfer ? Array.from(event.dataTransfer.files || []) : [];
|
||||
var raw = event.dataTransfer ? event.dataTransfer.getData(FILETREE_DRAG_MIME) || event.dataTransfer.getData('application/x-mnote-file-tree') || '' : '';
|
||||
var rowIds = draggingFileTreeRowIds.slice();
|
||||
if (raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed.rowIds)) rowIds = parsed.rowIds;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!files.length && !rowIds.length) return;
|
||||
event.preventDefault();
|
||||
var detail = {
|
||||
workspaceId: resolveWorkspaceId(targetRow || fileTree),
|
||||
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
|
||||
targetRowKind: targetRow ? targetRow.getAttribute('data-row-kind') : 'root',
|
||||
documentId: targetRow ? targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id') : null,
|
||||
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null
|
||||
};
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow = null;
|
||||
if (files.length) {
|
||||
dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files }));
|
||||
} else {
|
||||
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true }));
|
||||
}
|
||||
draggingFileTreeRowIds = [];
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function() {
|
||||
draggingPageNodeId = '';
|
||||
draggingFileTreeRowIds = [];
|
||||
clearPageDropFeedback();
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow = null;
|
||||
});
|
||||
|
||||
window.addEventListener('tree:title-updated', function(event) {
|
||||
var detail = event.detail || {};
|
||||
updateTitleEverywhere(detail.documentId, detail.title);
|
||||
scheduleProjectionRefresh(detail.workspaceId);
|
||||
});
|
||||
|
||||
window.addEventListener('tree:snapshot', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
if (renderPageProjection(payload)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot');
|
||||
}
|
||||
scheduleProjectionRefresh(payload && payload.workspaceId);
|
||||
});
|
||||
|
||||
window.addEventListener('tree:delta', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
var data = payload && (payload.data || payload.delta || payload);
|
||||
var documents = data && (data.upsertDocuments || data.upsert_documents);
|
||||
if (Array.isArray(documents)) {
|
||||
documents.forEach(function(doc) {
|
||||
updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题');
|
||||
});
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
||||
scheduleProjectionRefresh(payload && payload.workspaceId);
|
||||
});
|
||||
|
||||
window.addEventListener('tree:resync', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
|
||||
scheduleProjectionRefresh(payload && payload.workspaceId);
|
||||
});
|
||||
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
var activeId = currentDocumentId();
|
||||
if (tree && activeId) {
|
||||
var activeRow = tree.querySelector('.tree-row[data-node-id="' + cssEscape(activeId) + '"]');
|
||||
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'true');
|
||||
}
|
||||
})();
|
||||
"##;
|
||||
|
||||
const TREE_LIVE_CONTROLLER_JS: &str = r##"
|
||||
(function(){
|
||||
if (window.__mnoteTreeLiveControllerStarted) return;
|
||||
window.__mnoteTreeLiveControllerStarted = true;
|
||||
|
||||
function readBootstrap() {
|
||||
var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__');
|
||||
var fallback = {
|
||||
schema: 'mnote.tree_live_bootstrap.v1',
|
||||
transport: 'convex-command-log-sse',
|
||||
endpoint: '/api/tree/events',
|
||||
resyncEndpoint: '/api/tree/projections/sidebar',
|
||||
rootIds: [],
|
||||
initialRevision: null
|
||||
};
|
||||
if (!script || !script.textContent) return fallback;
|
||||
try {
|
||||
return Object.assign(fallback, JSON.parse(script.textContent));
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceId() {
|
||||
var withWorkspace = document.querySelector('[data-workspace-id]');
|
||||
if (withWorkspace) {
|
||||
var value = (withWorkspace.getAttribute('data-workspace-id') || '').trim();
|
||||
if (value) return value;
|
||||
}
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
return (params.get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
function dispatchTreeEvent(name, detail) {
|
||||
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
||||
}
|
||||
|
||||
function applyStatus(status) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-status', status);
|
||||
}
|
||||
|
||||
function applyTransport(transport) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-transport', transport || 'convex-command-log-sse');
|
||||
}
|
||||
|
||||
function closeActiveSource() {
|
||||
var source = window.__mnoteTreeLiveEventSource;
|
||||
if (source && typeof source.close === 'function') {
|
||||
source.close();
|
||||
window.__mnoteTreeLiveEventSource = null;
|
||||
applyStatus('closed');
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!('EventSource' in window)) {
|
||||
applyStatus('unsupported');
|
||||
return;
|
||||
}
|
||||
var bootstrap = readBootstrap();
|
||||
applyTransport(bootstrap.transport || 'convex-command-log-sse');
|
||||
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
|
||||
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
|
||||
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
|
||||
}
|
||||
var failures = 0;
|
||||
var source = new EventSource(url.toString());
|
||||
window.__mnoteTreeLiveEventSource = source;
|
||||
applyStatus('connecting');
|
||||
|
||||
source.addEventListener('open', function(){
|
||||
failures = 0;
|
||||
applyStatus('connected');
|
||||
});
|
||||
|
||||
source.addEventListener('snapshot', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
var revision = payload.revision || payload.cursor || event.lastEventId || null;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
|
||||
dispatchTreeEvent('tree:snapshot', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('delta', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
var revision = payload.revision || payload.cursor || event.lastEventId || null;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
|
||||
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('resync', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
var revision = payload.revision || payload.cursor || event.lastEventId || null;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
|
||||
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.onerror = function(){
|
||||
failures += 1;
|
||||
applyStatus(failures >= 3 ? 'resync-pending' : 'reconnecting');
|
||||
if (failures >= 3) {
|
||||
dispatchTreeEvent('tree:resync-requested', { endpoint: bootstrap.resyncEndpoint, workspaceId: workspaceId });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.__mnoteTreeLiveClose = closeActiveSource;
|
||||
window.addEventListener('pagehide', closeActiveSource, { once: true });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
"##;
|
||||
@@ -198,6 +772,17 @@ pub fn PageLayout(
|
||||
None,
|
||||
)
|
||||
});
|
||||
let tree_live_bootstrap = serde_json::json!({
|
||||
"schema": "mnote.tree_live_bootstrap.v1",
|
||||
"transport": "convex-command-log-sse",
|
||||
"workspaceId": null,
|
||||
"rootIds": [],
|
||||
"initialRevision": null,
|
||||
"endpoint": "/api/tree/events",
|
||||
"resyncEndpoint": "/api/tree/projections/sidebar",
|
||||
"views": ["page-tree", "file-tree"]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
view! {
|
||||
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
@@ -208,15 +793,17 @@ pub fn PageLayout(
|
||||
<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="/help" 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>
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索"><svg class="mnote-symbol nav-icon" data-icon="search" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="5.5"></circle><path d="m15 15 5 5"></path></svg></a>
|
||||
<a href="/graph" title="关系图" aria-label="关系图"><svg class="mnote-symbol nav-icon" data-icon="account_tree" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h5v5H6z"></path><path d="M15 3h5v5h-5z"></path><path d="M15 16h5v5h-5z"></path><path d="M11 8.5h2.5a4 4 0 0 0 4-4"></path><path d="M11 8.5h2.5a4 4 0 0 1 4 4V16"></path></svg></a>
|
||||
<a href="/actions" title="快捷动作" aria-label="快捷动作"><svg class="mnote-symbol nav-icon" data-icon="bolt" viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 14h6l-1 8 9-13h-6z"></path></svg></a>
|
||||
<a href="/help" title="帮助" aria-label="帮助"><svg class="mnote-symbol nav-icon" data-icon="help" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M9.8 9a2.4 2.4 0 0 1 4.5 1.2c0 1.7-2.3 2-2.3 3.8"></path><path d="M12 17.5h.01"></path></svg></a>
|
||||
<a href="/files" title="文件" aria-label="文件"><svg class="mnote-symbol nav-icon" data-icon="inventory_2" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v4H4z"></path><path d="M6 10v9h12v-9"></path><path d="M9 14h6"></path></svg></a>
|
||||
<a href="/more" title="更多" aria-label="更多"><svg class="mnote-symbol nav-icon" data-icon="more_horiz" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h.01"></path><path d="M12 12h.01"></path><path d="M19 12h.01"></path></svg></a>
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
|
||||
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
||||
<script id="mnote-tree-live-controller" inner_html={TREE_LIVE_CONTROLLER_JS.to_string()}></script>
|
||||
</aside>
|
||||
<div class="mnote-main">
|
||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||
@@ -225,7 +812,7 @@ pub fn PageLayout(
|
||||
<nav class="wolai-breadcrumb" aria-label="页面路径">
|
||||
<span class="wolai-breadcrumb-root">"The Digital Atelier"</span>
|
||||
<span class="wolai-breadcrumb-separator" aria-hidden="true">"/"</span>
|
||||
<span class="wolai-breadcrumb-current"><span class="wolai-home-icon" aria-hidden="true">"⌂"</span>{topbar_title}</span>
|
||||
<span class="wolai-breadcrumb-current"><svg class="mnote-symbol wolai-home-icon" data-icon="home" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5 12 3l8 7.5"></path><path d="M6.5 9.5V21h11V9.5"></path><path d="M9.5 21v-6h5v6"></path></svg><span data-page-title-current="true">{topbar_title}</span></span>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="wolai-topbar-actions" aria-label="页面操作">
|
||||
@@ -248,3 +835,34 @@ pub fn PageLayout(
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS};
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("dragstart"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-drop-feedback"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("action: 'move'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use leptos::prelude::*;
|
||||
/// MNOTE 搜索页面
|
||||
///
|
||||
/// 渲染搜索页面的 SSR 壳结构:
|
||||
/// - `<main id="mnote-search-shell" data-island-host="react_search_palette">`
|
||||
/// - `<main id="mnote-search-shell" data-island-host="search_interaction_island">`
|
||||
/// - 搜索标题区域
|
||||
/// - 搜索岛占位区
|
||||
#[component]
|
||||
@@ -26,10 +26,14 @@ pub fn SearchPage(
|
||||
/// 工作区名称(可选)
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
/// 服务端首批搜索结果 HTML(可选)
|
||||
#[prop(optional)]
|
||||
initial_results_html: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let initial_results_html = initial_results_html.unwrap_or_default();
|
||||
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">
|
||||
<main id="mnote-search-shell" data-island-host="search_interaction_island">
|
||||
<header class="search-header">
|
||||
<h1>{"搜索"}</h1>
|
||||
<p class="search-meta">
|
||||
@@ -43,7 +47,8 @@ pub fn SearchPage(
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
<section id="mnote-search-island" data-react-island="search_palette"></section>
|
||||
<section id="mnote-search-results" data-search-results-owner="rust-kernel" inner_html={initial_results_html}></section>
|
||||
<section id="mnote-search-island" data-react-island="search_interaction"></section>
|
||||
</main>
|
||||
</PageLayout>
|
||||
}
|
||||
@@ -60,9 +65,9 @@ mod tests {
|
||||
<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("data-island-host=\"search_interaction_island\""));
|
||||
assert!(html.contains("mnote-search-island"));
|
||||
assert!(html.contains("data-react-island=\"search_palette\""));
|
||||
assert!(html.contains("mnote-search-results"));
|
||||
assert!(html.contains("搜索"));
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,30 @@ a:hover {
|
||||
color: var(--wolai-accent-hover);
|
||||
}
|
||||
|
||||
.mnote-symbol {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
color: currentColor;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vertical-align: -0.18em;
|
||||
}
|
||||
|
||||
.mnote-symbol[data-icon="home"] {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.mnote-symbol--document {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
stroke-width: 1.35;
|
||||
}
|
||||
|
||||
/* ===== 滚动条(悬停时显示) ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -1138,6 +1162,13 @@ body {
|
||||
color: var(--atelier-text);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-feedback="true"],
|
||||
.sidebar-tree .tree-row[data-drop-target="true"],
|
||||
.sidebar-tree .tree-root[data-drop-target="true"] {
|
||||
background: rgba(0, 110, 40, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle,
|
||||
.sidebar-tree .tree-spacer {
|
||||
width: 16px;
|
||||
@@ -1371,11 +1402,11 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #111111;
|
||||
font-size: 72px;
|
||||
line-height: 1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.document-title-heading,
|
||||
.document-shell-header h1 {
|
||||
padding: 0;
|
||||
margin: 0 0 14px;
|
||||
@@ -1387,6 +1418,32 @@ body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-title-input {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
display: block;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
letter-spacing: 0;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.document-title-input::placeholder {
|
||||
color: #B8B5AF;
|
||||
}
|
||||
|
||||
.document-title-input[data-title-save-status="error"] {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #D6545D;
|
||||
text-underline-offset: 6px;
|
||||
}
|
||||
|
||||
.document-shell-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1439,6 +1496,24 @@ body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .block-handle-shell {
|
||||
left: -52px !important;
|
||||
z-index: 5;
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .block-handle-shell:hover,
|
||||
#mnote-leptos-tiptap-island-editor-root .block-handle-shell[data-dragging="true"] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .block-handle-shell .block-handle-insert,
|
||||
#mnote-leptos-tiptap-island-editor-root .block-handle-shell .block-handle-trigger {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .ProseMirror p {
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
@@ -1530,12 +1605,20 @@ body {
|
||||
|
||||
.document-page-icon {
|
||||
height: 60px;
|
||||
font-size: 54px;
|
||||
}
|
||||
|
||||
.document-page-icon .mnote-symbol--document {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.document-shell-header h1 {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
#mnote-leptos-tiptap-island-editor-root .block-handle-shell {
|
||||
left: -36px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -1613,6 +1696,16 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains(".ProseMirror"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_keeps_tiptap_handles_in_margin_and_title_editable() {
|
||||
assert!(MNOTE_CSS.contains(".document-title-input"));
|
||||
assert!(MNOTE_CSS.contains(".block-handle-shell"));
|
||||
assert!(MNOTE_CSS.contains("left: -52px !important"));
|
||||
assert!(MNOTE_CSS.contains("pointer-events: none"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-symbol"));
|
||||
assert!(MNOTE_CSS.contains("data-icon=\"home\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
|
||||
@@ -57,18 +57,46 @@ fn render_filetree_row(
|
||||
row: &FileTreeRenderRow,
|
||||
children_by_parent: &BTreeMap<Option<String>, Vec<FileTreeRenderRow>>,
|
||||
) {
|
||||
let toggle_html = if row.expandable {
|
||||
format!(
|
||||
r#"<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="{row_id}" aria-label="{label} {title}">{marker}</button>"#,
|
||||
row_id = escape_html(&row.row_id),
|
||||
label = if row.expanded { "折叠" } else { "展开" },
|
||||
title = escape_html(&row.title),
|
||||
marker = if row.expanded { "▾" } else { "▸" },
|
||||
)
|
||||
} else {
|
||||
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
|
||||
};
|
||||
let parent_attr = row
|
||||
.parent_node_id
|
||||
.as_deref()
|
||||
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
|
||||
.unwrap_or_default();
|
||||
let create_action_html = if row.row_kind == "document" {
|
||||
format!(
|
||||
r#"<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="{row_id}" data-node-id="{node_id}" aria-label="新建子页面">+</button>"#,
|
||||
row_id = escape_html(&row.row_id),
|
||||
node_id = escape_html(&row.node_id),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}" data-document-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
test_id = row_test_id(&row.row_kind),
|
||||
row_id = escape_html(&row.row_id),
|
||||
row_kind = escape_html(&row.row_kind),
|
||||
parent_attr = parent_attr,
|
||||
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
toggle_html = toggle_html,
|
||||
icon_kind = escape_html(&row.icon_kind),
|
||||
create_action_html = create_action_html,
|
||||
title = escape_html(&row.title),
|
||||
));
|
||||
if row.expandable && row.expanded {
|
||||
@@ -165,6 +193,7 @@ mod tests {
|
||||
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
|
||||
assert!(html.contains("data-testid=\"filetree-doc-row\""));
|
||||
assert!(html.contains("data-testid=\"filetree-index-row\""));
|
||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-selected=\"true\""));
|
||||
|
||||
@@ -102,13 +102,13 @@ pub fn build_workspace_shell_projection(
|
||||
id: "trash".into(),
|
||||
label: "垃圾箱".into(),
|
||||
href: "/trash".into(),
|
||||
icon: "⌫".into(),
|
||||
icon: "delete".into(),
|
||||
},
|
||||
WorkspaceShellEntry {
|
||||
id: "templates".into(),
|
||||
label: "模板中心".into(),
|
||||
href: "/templates".into(),
|
||||
icon: "◇".into(),
|
||||
icon: "inventory_2".into(),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -321,6 +321,10 @@ mod tests {
|
||||
assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
|
||||
assert!(html.contains("data-mnote-action=\"create-page\""));
|
||||
assert!(html.contains("class=\"mnote-symbol"));
|
||||
assert!(html.contains("data-icon=\"home\""));
|
||||
assert!(html.contains("data-icon=\"star\""));
|
||||
assert!(html.contains("data-icon=\"delete\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -422,9 +426,9 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
format!(
|
||||
r#"<a href="{}" class="wolai-footer-entry"><span>{}</span>{}</a>"#,
|
||||
r#"<a href="{}" class="wolai-footer-entry">{}{}</a>"#,
|
||||
escape_html(&entry.href),
|
||||
escape_html(&entry.icon),
|
||||
render_symbol(&entry.icon, "wolai-footer-icon"),
|
||||
escape_html(&entry.label),
|
||||
)
|
||||
})
|
||||
@@ -432,7 +436,9 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
.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="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel"><span class="wolai-folder-icon">▱</span>Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
render_symbol("star", "wolai-section-icon"),
|
||||
render_symbol("folder_open", "wolai-folder-icon"),
|
||||
escape_html(&projection.workspace_id),
|
||||
)
|
||||
}
|
||||
@@ -455,16 +461,55 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
|
||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
|
||||
escape_html(&item.href),
|
||||
escape_html(&item.id),
|
||||
escape_html(&item.id),
|
||||
item.depth,
|
||||
item.active,
|
||||
escape_html(item.icon.as_deref().unwrap_or("▣")),
|
||||
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
|
||||
escape_html(&item.title),
|
||||
)
|
||||
}
|
||||
|
||||
fn item_icon_name(icon: Option<&str>) -> &'static str {
|
||||
match icon.unwrap_or_default().trim() {
|
||||
"star" | "★" => "star",
|
||||
"folder" | "folder_open" | "▱" => "folder_open",
|
||||
"delete" | "trash" | "⌫" => "delete",
|
||||
"inventory_2" | "template" | "◇" => "inventory_2",
|
||||
"home" | "page" | "▣" | "⌂" | "" => "home",
|
||||
_ => "home",
|
||||
}
|
||||
}
|
||||
|
||||
fn render_symbol(icon: &str, class_name: &str) -> String {
|
||||
let icon = item_icon_name(Some(icon));
|
||||
let body = match icon {
|
||||
"delete" => {
|
||||
r#"<path d="M5 6h14"></path><path d="M9 6V4h6v2"></path><path d="M8 6l1 14h6l1-14"></path><path d="M10.5 10v6"></path><path d="M13.5 10v6"></path>"#
|
||||
}
|
||||
"folder_open" => {
|
||||
r#"<path d="M3.5 6.5h6l2 2h9"></path><path d="M4 8.5v9.5h14.5l2-7.5H7l-3 7.5"></path>"#
|
||||
}
|
||||
"inventory_2" => {
|
||||
r#"<path d="M4 6h16v4H4z"></path><path d="M6 10v9h12v-9"></path><path d="M9 14h6"></path>"#
|
||||
}
|
||||
"star" => {
|
||||
r#"<path d="m12 3 2.5 5.2 5.7.8-4.1 4 1 5.7-5.1-2.7-5.1 2.7 1-5.7-4.1-4 5.7-.8z"></path>"#
|
||||
}
|
||||
_ => {
|
||||
r#"<path d="M4 10.5 12 3l8 7.5"></path><path d="M6.5 9.5V21h11V9.5"></path><path d="M9.5 21v-6h5v6"></path>"#
|
||||
}
|
||||
};
|
||||
format!(
|
||||
r#"<svg class="mnote-symbol {}" data-icon="{}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">{}</svg>"#,
|
||||
escape_html(class_name),
|
||||
escape_html(icon),
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
|
||||
@@ -68,6 +68,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
||||
"documents.options.update" => "documents:updateOptions",
|
||||
"page.layout.updateOptions" => "documents:updateOptions",
|
||||
"mindmaps.put" => "mindmaps:put",
|
||||
"mindmap.command.apply" => "mindmaps:applyCommand",
|
||||
"mindmaps.delete" => "mindmaps:softDelete",
|
||||
"mindmaps.restore" => "mindmaps:restore",
|
||||
"mindmaps.purge" => "mindmaps:purge",
|
||||
@@ -97,10 +98,13 @@ pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
|
||||
"bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview",
|
||||
"documents.meta.get" => "documents:getMeta",
|
||||
"documents.content.get" => "documents:getContent",
|
||||
"page.aggregate.get" => "documents:getPageAggregate",
|
||||
"mindmaps.get" => "mindmaps:get",
|
||||
"mindmap.projection.get" => "mindmaps:getProjection",
|
||||
"blocks.get" => "blocks:getById",
|
||||
"sidebar.dataset.list" => "sidebar:datasetList",
|
||||
"search.documents" => "search:documents",
|
||||
"search.documents.query" => "search:documents",
|
||||
"search.recent" => "search:recent",
|
||||
"get_page" => "pages:get",
|
||||
"list_page_blocks" => "blocks:list_by_page",
|
||||
|
||||
@@ -71,8 +71,8 @@ async function checkSearchShell() {
|
||||
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Search shell 缺少 mnote-web owner header");
|
||||
assert(response.headers.get("x-mnote-web-shell") === "search", "Search shell 缺少 search shell header");
|
||||
assert(text.includes("mnote.search_shell.v1"), "Search shell 缺少 contract schema");
|
||||
assert(text.includes("react_search_palette"), "Search shell 缺少 search palette island");
|
||||
return { owner: "mnote-web", shell: "search", island: "react_search_palette" };
|
||||
assert(text.includes("search_interaction_island"), "Search shell 缺少 search interaction island");
|
||||
return { owner: "mnote-web", shell: "search", island: "search_interaction_island" };
|
||||
}
|
||||
|
||||
async function checkAiBridge() {
|
||||
|
||||
@@ -96,6 +96,11 @@ async function readText(baseUrl, path, init) {
|
||||
const text = await response.text();
|
||||
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
|
||||
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", `${path} 缺少 mnote-web owner`);
|
||||
assert.equal(
|
||||
response.headers.get("x-mnote-legacy-upstream"),
|
||||
null,
|
||||
`${path} 默认主路径不应出现 Next fallback header`,
|
||||
);
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
@@ -116,6 +121,10 @@ async function validateSkipNextRuntimePlan() {
|
||||
}
|
||||
|
||||
async function validateCoreShellsWithoutNext(baseUrl) {
|
||||
const root = await readText(baseUrl, "/?workspaceId=ws_demo");
|
||||
assert.match(root.text, /data-mnote-shell="workspace"/);
|
||||
assert.doesNotMatch(root.text, /next-app-router/i);
|
||||
|
||||
const auth = await readText(baseUrl, "/auth");
|
||||
assert.match(auth.text, /data-mnote-shell="auth"/);
|
||||
assert.doesNotMatch(auth.text, /next-app-router/i);
|
||||
@@ -134,6 +143,11 @@ async function validateCoreShellsWithoutNext(baseUrl) {
|
||||
const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust");
|
||||
assert.equal(search.response.headers.get("x-mnote-web-shell"), "search");
|
||||
assert.match(search.text, /mnote\.search_shell\.v1/);
|
||||
|
||||
const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1");
|
||||
assert.equal(mindmap.response.headers.get("x-mnote-web-shell"), "mindmap");
|
||||
assert.match(mindmap.text, /mnote\.mindmap_shell\.v1/);
|
||||
assert.doesNotMatch(mindmap.text, /next-app-router/i);
|
||||
}
|
||||
|
||||
async function validateDocs() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
|
||||
|
||||
async function fetchText(path) {
|
||||
const response = await fetch(`${BASE_URL}${path}`);
|
||||
const text = await response.text();
|
||||
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const page = await fetchText("/documents/doc_1?workspaceId=ws_demo");
|
||||
assert.match(page.text, /__MNOTE_TREE_LIVE_BOOTSTRAP__/);
|
||||
assert.match(page.text, /mnote\.tree_live_bootstrap\.v1/);
|
||||
assert.match(page.text, /\/api\/tree\/events/);
|
||||
assert.match(page.text, /tree:snapshot/);
|
||||
assert.match(page.text, /tree:delta/);
|
||||
assert.match(page.text, /tree:resync/);
|
||||
|
||||
const events = await fetchText("/api/tree/events?workspaceId=ws_demo&maxPolls=0");
|
||||
assert.match(events.response.headers.get("content-type") || "", /text\/event-stream/);
|
||||
assert.equal(events.response.headers.get("x-mnote-tree-stream-owner"), "rust-web");
|
||||
assert.match(events.text, /event:\s*snapshot/);
|
||||
assert.match(events.text, /id:\s*/);
|
||||
assert.match(events.text, /"revision"/);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events" }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(`${BASE_URL}/search?workspaceId=ws_demo&q=Rust`);
|
||||
const html = await response.text();
|
||||
assert.equal(response.status, 200, `/search 请求失败: ${response.status} ${html.slice(0, 200)}`);
|
||||
assert.equal(response.headers.get("x-mnote-web-shell"), "search");
|
||||
assert.match(html, /mnote\.search_shell\.v1/);
|
||||
assert.match(html, /search\.documents\.query/);
|
||||
assert.match(html, /data-search-results-owner="rust-kernel"/);
|
||||
assert.match(html, /search-result/);
|
||||
assert.doesNotMatch(html, /react_search_palette/);
|
||||
|
||||
const api = await fetch(`${BASE_URL}/api/search/documents`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_demo",
|
||||
query: "Rust",
|
||||
filters: {
|
||||
titleOnly: false,
|
||||
exact: false,
|
||||
includeOcr: false,
|
||||
onlyCurrentPage: false,
|
||||
timeRange: "any",
|
||||
timeField: "updated",
|
||||
},
|
||||
}),
|
||||
});
|
||||
const payload = await api.json();
|
||||
assert.equal(api.status, 200, `/api/search/documents 请求失败: ${api.status}`);
|
||||
assert.equal(payload.projectionOwner, "rust-kernel");
|
||||
assert.equal(payload.meta.queryName, "search.documents.query");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, projectionOwner: payload.projectionOwner }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(`${BASE_URL}/api/hermes/bridge`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "生成摘要" }],
|
||||
context: { documentId: "doc_1", workspaceId: "ws_demo" },
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200, `/api/hermes/bridge 请求失败: ${response.status}`);
|
||||
assert.equal(response.headers.get("x-mnote-ai-bridge-owner"), "rust-web-hermes");
|
||||
assert.equal(payload.canonicalRoute, "/api/hermes/bridge");
|
||||
assert.match(payload.eventStreamEndpoint || "", /\/api\/hermes\/events\//);
|
||||
assert.equal(payload.contract.structuredWriteOwner, "rust-web-hermes");
|
||||
assert.deepEqual(payload.structuredWrite.allowedCommands, [
|
||||
"page.body.save",
|
||||
"tree.node.create",
|
||||
"kernel.edge.attach",
|
||||
]);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, bridgeOwner: "rust-web-hermes" }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(`${BASE_URL}/mindmap/doc_1/mind_1`);
|
||||
const html = await response.text();
|
||||
assert.equal(response.status, 200, `/mindmap 请求失败: ${response.status} ${html.slice(0, 200)}`);
|
||||
assert.equal(response.headers.get("x-mnote-web-shell"), "mindmap");
|
||||
assert.match(html, /mnote\.mindmap_shell\.v1/);
|
||||
assert.match(html, /mindmap\.projection\.get/);
|
||||
assert.match(html, /mindmap\.command\.apply/);
|
||||
assert.match(html, /rust-kernel/);
|
||||
assert.doesNotMatch(html, /next-app-router/);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, projectionOwner: "rust-kernel" }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -692,7 +692,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
const res = await fetch(DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT.runEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface DocumentSearchResult {
|
||||
score: number;
|
||||
nodeId?: string | null;
|
||||
subtreeRootId?: string | null;
|
||||
projectionOwner?: "rust-kernel" | "compat-index";
|
||||
evidence?: Array<{
|
||||
kind: string;
|
||||
nodeId?: string | null;
|
||||
@@ -44,6 +45,7 @@ export interface DocumentSearchResult {
|
||||
}
|
||||
|
||||
export interface DocumentSearchResponse {
|
||||
projectionOwner?: "rust-kernel" | "compat-index";
|
||||
results: DocumentSearchResult[];
|
||||
recent: DocumentSearchResult[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user