2026-04-16 15:24:37 +08:00
|
|
|
|
use base64::Engine;
|
|
|
|
|
|
use bridge_runtime::{
|
|
|
|
|
|
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
|
|
|
|
|
|
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
|
|
|
|
|
|
};
|
2026-04-19 21:03:25 +08:00
|
|
|
|
use clap::ValueEnum;
|
2026-04-15 20:01:12 +08:00
|
|
|
|
use core_protocol::{
|
|
|
|
|
|
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
|
|
|
|
|
|
ListSidebarDataset, MindmapOp, MindmapTreeNode, MoveDocumentPage, PatchPageBlock, PutMindmap,
|
|
|
|
|
|
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
|
|
|
|
|
|
SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
};
|
2026-04-19 21:03:25 +08:00
|
|
|
|
use mnote_editor_core::{
|
|
|
|
|
|
apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock,
|
|
|
|
|
|
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
|
|
|
|
|
|
VisibilitySnapshot,
|
|
|
|
|
|
};
|
2026-04-16 15:24:37 +08:00
|
|
|
|
use reqwest::blocking::Client;
|
2026-04-15 20:01:12 +08:00
|
|
|
|
use serde::Serialize;
|
|
|
|
|
|
use serde_json::{json, Value};
|
2026-04-16 15:24:37 +08:00
|
|
|
|
use std::env;
|
2026-04-15 20:01:12 +08:00
|
|
|
|
use storage_convex_bridge::{build_query_request, build_write_request, BridgeContext};
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct CliContext {
|
|
|
|
|
|
pub actor_id: String,
|
|
|
|
|
|
pub actor_type: String,
|
|
|
|
|
|
pub session_id: Option<String>,
|
|
|
|
|
|
pub reason: Option<String>,
|
|
|
|
|
|
pub idempotency_key: Option<String>,
|
|
|
|
|
|
pub validate_only: bool,
|
|
|
|
|
|
pub dry_run: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for CliContext {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
actor_id: "cli_user".into(),
|
|
|
|
|
|
actor_type: "human".into(),
|
|
|
|
|
|
session_id: None,
|
|
|
|
|
|
reason: None,
|
|
|
|
|
|
idempotency_key: None,
|
|
|
|
|
|
validate_only: false,
|
|
|
|
|
|
dry_run: false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct CliError {
|
|
|
|
|
|
pub code: &'static str,
|
|
|
|
|
|
pub message: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl CliError {
|
|
|
|
|
|
pub fn validation(message: impl Into<String>) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
code: "VALIDATION_ERROR",
|
|
|
|
|
|
message: message.into(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 15:24:37 +08:00
|
|
|
|
|
|
|
|
|
|
pub fn transport(message: impl Into<String>) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
code: "TRANSPORT_ERROR",
|
|
|
|
|
|
message: message.into(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-15 20:01:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub type CliResult<T> = Result<T, CliError>;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct CliJsonOutput {
|
|
|
|
|
|
pub ok: bool,
|
|
|
|
|
|
pub entrypoint: &'static str,
|
|
|
|
|
|
pub domain: String,
|
|
|
|
|
|
pub action: String,
|
|
|
|
|
|
pub context: CliOutputContext,
|
|
|
|
|
|
pub operation: CliOperationOutput,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct CliOutputContext {
|
|
|
|
|
|
pub request_id: String,
|
|
|
|
|
|
pub trace_id: String,
|
|
|
|
|
|
pub actor_id: String,
|
|
|
|
|
|
pub actor_type: String,
|
|
|
|
|
|
pub session_id: Option<String>,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
pub reason: Option<String>,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
pub idempotency_key: Option<String>,
|
|
|
|
|
|
pub validate_only: bool,
|
|
|
|
|
|
pub dry_run: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
|
|
|
|
|
pub enum CliOperationOutput {
|
|
|
|
|
|
Command {
|
|
|
|
|
|
name: String,
|
|
|
|
|
|
command_id: String,
|
|
|
|
|
|
normalized_input: Value,
|
|
|
|
|
|
transport: CliTransportPlan,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
execution: Option<Value>,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
Query {
|
|
|
|
|
|
name: String,
|
|
|
|
|
|
normalized_input: Value,
|
|
|
|
|
|
transport: CliTransportPlan,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
execution: Option<Value>,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
Tool {
|
|
|
|
|
|
tool_name: String,
|
|
|
|
|
|
invocation_kind: String,
|
|
|
|
|
|
execution_mode: String,
|
|
|
|
|
|
toolset_id: String,
|
|
|
|
|
|
effect: String,
|
|
|
|
|
|
requires_confirmation: bool,
|
|
|
|
|
|
normalized_input: Value,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
execution: Option<Value>,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct CliTransportPlan {
|
|
|
|
|
|
pub kind: String,
|
|
|
|
|
|
pub function_name: String,
|
|
|
|
|
|
pub payload_json: String,
|
|
|
|
|
|
pub args_json: Value,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 21:03:25 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct EditorCliOutput {
|
|
|
|
|
|
pub ok: bool,
|
|
|
|
|
|
pub entrypoint: &'static str,
|
|
|
|
|
|
pub operation: &'static str,
|
|
|
|
|
|
pub block_count: usize,
|
|
|
|
|
|
pub output_markdown: String,
|
|
|
|
|
|
pub visible_block_ids: Vec<String>,
|
|
|
|
|
|
pub outline: Vec<EditorOutlineItem>,
|
|
|
|
|
|
pub audit: Vec<EditorAuditItem>,
|
|
|
|
|
|
pub change_report: EditorChangeReportOutput,
|
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub undo_applied: Option<bool>,
|
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub redo_applied: Option<bool>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct EditorOutlineItem {
|
|
|
|
|
|
pub block_id: String,
|
|
|
|
|
|
pub level: u8,
|
|
|
|
|
|
pub title: String,
|
|
|
|
|
|
pub depth: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct EditorAuditItem {
|
|
|
|
|
|
pub command: String,
|
|
|
|
|
|
pub target_block_id: Option<String>,
|
|
|
|
|
|
pub before: Option<EditorBlockSnapshot>,
|
|
|
|
|
|
pub after: Option<EditorBlockSnapshot>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct EditorBlockSnapshot {
|
|
|
|
|
|
pub block_id: String,
|
|
|
|
|
|
pub block_type: String,
|
|
|
|
|
|
pub text: String,
|
|
|
|
|
|
pub parent_id: Option<String>,
|
|
|
|
|
|
pub indent: u16,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct EditorChangeReportOutput {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub created_blocks: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub updated_blocks: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub moved_blocks: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub removed_blocks: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub notes: Vec<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
|
|
|
|
pub enum EditorInputKindArg {
|
|
|
|
|
|
PlainText,
|
|
|
|
|
|
Markdown,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
|
|
|
|
pub enum EditorAiScenarioArg {
|
|
|
|
|
|
MeetingNotesToTodos,
|
|
|
|
|
|
LongParagraphToTitle,
|
|
|
|
|
|
PageReorder,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:24:37 +08:00
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct PageCreateArgs<'a> {
|
|
|
|
|
|
pub page_id: &'a str,
|
|
|
|
|
|
pub workspace_id: &'a str,
|
|
|
|
|
|
pub parent_id: Option<&'a str>,
|
|
|
|
|
|
pub title: &'a str,
|
|
|
|
|
|
pub access_scope: &'a str,
|
|
|
|
|
|
pub content_json: &'a str,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct PageMoveArgs<'a> {
|
|
|
|
|
|
pub page_id: &'a str,
|
|
|
|
|
|
pub workspace_id: Option<&'a str>,
|
|
|
|
|
|
pub parent_id: Option<&'a str>,
|
|
|
|
|
|
pub sort_order: i64,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct PageDeleteArgs<'a> {
|
|
|
|
|
|
pub page_id: &'a str,
|
|
|
|
|
|
pub workspace_id: Option<&'a str>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct PageRestoreArgs<'a> {
|
|
|
|
|
|
pub page_id: &'a str,
|
|
|
|
|
|
pub workspace_id: Option<&'a str>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct BlockMoveArgs<'a> {
|
|
|
|
|
|
pub source_document_id: &'a str,
|
|
|
|
|
|
pub block_id: &'a str,
|
|
|
|
|
|
pub target_document_id: &'a str,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct BlockEmbedArgs<'a> {
|
|
|
|
|
|
pub source_document_id: &'a str,
|
|
|
|
|
|
pub block_id: &'a str,
|
|
|
|
|
|
pub target_document_id: &'a str,
|
|
|
|
|
|
pub target_block_id: Option<&'a str>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
pub fn supported_command_surface() -> Vec<&'static str> {
|
|
|
|
|
|
let mut surface = vec![
|
|
|
|
|
|
"page get",
|
|
|
|
|
|
"page title",
|
|
|
|
|
|
"page save",
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"page create",
|
|
|
|
|
|
"page move",
|
|
|
|
|
|
"page delete",
|
|
|
|
|
|
"page restore",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
"block insert",
|
|
|
|
|
|
"block patch",
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"block move",
|
|
|
|
|
|
"block embed",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
"mindmap get",
|
|
|
|
|
|
"mindmap put",
|
|
|
|
|
|
"mindmap op",
|
|
|
|
|
|
"search documents",
|
|
|
|
|
|
"search blocks",
|
|
|
|
|
|
"sidebar dataset",
|
2026-04-19 21:03:25 +08:00
|
|
|
|
"editor markdown-roundtrip",
|
|
|
|
|
|
"editor session-demo",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
"tool run",
|
|
|
|
|
|
];
|
|
|
|
|
|
for tool_name in default_tool_registry().tool_names() {
|
2026-04-16 15:24:37 +08:00
|
|
|
|
surface.push(Box::leak(
|
|
|
|
|
|
format!("tool run --tool-name {tool_name}").into_boxed_str(),
|
|
|
|
|
|
));
|
2026-04-15 20:01:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
surface
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn supported_json_contracts() -> Vec<&'static str> {
|
|
|
|
|
|
let mut contracts = vec![
|
|
|
|
|
|
"page.get",
|
|
|
|
|
|
"page.title",
|
|
|
|
|
|
"page.save",
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"page.create",
|
|
|
|
|
|
"page.move",
|
|
|
|
|
|
"page.delete",
|
|
|
|
|
|
"page.restore",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
"block.insert",
|
|
|
|
|
|
"block.patch",
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"block.move",
|
|
|
|
|
|
"block.embed",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
"mindmap.get",
|
|
|
|
|
|
"mindmap.put",
|
|
|
|
|
|
"mindmap.op",
|
|
|
|
|
|
"search.documents",
|
|
|
|
|
|
"search.blocks",
|
|
|
|
|
|
"sidebar.dataset",
|
2026-04-19 21:03:25 +08:00
|
|
|
|
"editor.markdown_roundtrip",
|
|
|
|
|
|
"editor.session_demo",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
"tool.run",
|
|
|
|
|
|
];
|
|
|
|
|
|
for tool_name in default_tool_registry().tool_names() {
|
|
|
|
|
|
contracts.push(Box::leak(format!("tool.{tool_name}").into_boxed_str()));
|
|
|
|
|
|
}
|
|
|
|
|
|
contracts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 21:03:25 +08:00
|
|
|
|
pub fn editor_markdown_roundtrip(markdown: &str) -> CliResult<EditorCliOutput> {
|
|
|
|
|
|
let document = import_markdown(markdown).map_err(map_editor_error)?;
|
|
|
|
|
|
let projection = VisibilitySnapshot::derive(&document);
|
|
|
|
|
|
Ok(build_editor_output(
|
|
|
|
|
|
"markdown_roundtrip",
|
|
|
|
|
|
document.blocks().len(),
|
|
|
|
|
|
export_markdown(&document),
|
|
|
|
|
|
&projection,
|
|
|
|
|
|
Vec::new(),
|
|
|
|
|
|
EditorChangeReportOutput::default(),
|
|
|
|
|
|
None,
|
|
|
|
|
|
None,
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn editor_ai_pipeline(
|
|
|
|
|
|
kind: EditorInputKind,
|
|
|
|
|
|
scenario: EditorAiScenario,
|
|
|
|
|
|
input: &str,
|
|
|
|
|
|
) -> CliResult<EditorCliOutput> {
|
|
|
|
|
|
let result = apply_ai_pipeline(EditorPipelineRequest {
|
|
|
|
|
|
kind,
|
|
|
|
|
|
scenario,
|
|
|
|
|
|
input: input.to_string(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.map_err(map_editor_error)?;
|
|
|
|
|
|
let projection = VisibilitySnapshot::derive(&result.document);
|
|
|
|
|
|
Ok(build_editor_output(
|
|
|
|
|
|
"ai_pipeline",
|
|
|
|
|
|
result.document.blocks().len(),
|
|
|
|
|
|
result.markdown,
|
|
|
|
|
|
&projection,
|
|
|
|
|
|
result
|
|
|
|
|
|
.audit
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|item| EditorAuditItem {
|
|
|
|
|
|
command: item.command,
|
|
|
|
|
|
target_block_id: item.target_block_id,
|
|
|
|
|
|
before: item.before.map(|snapshot| EditorBlockSnapshot {
|
|
|
|
|
|
block_id: snapshot.block_id,
|
|
|
|
|
|
block_type: snapshot.block_type,
|
|
|
|
|
|
text: snapshot.text,
|
|
|
|
|
|
parent_id: snapshot.parent_id,
|
|
|
|
|
|
indent: snapshot.indent,
|
|
|
|
|
|
}),
|
|
|
|
|
|
after: item.after.map(|snapshot| EditorBlockSnapshot {
|
|
|
|
|
|
block_id: snapshot.block_id,
|
|
|
|
|
|
block_type: snapshot.block_type,
|
|
|
|
|
|
text: snapshot.text,
|
|
|
|
|
|
parent_id: snapshot.parent_id,
|
|
|
|
|
|
indent: snapshot.indent,
|
|
|
|
|
|
}),
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
EditorChangeReportOutput {
|
|
|
|
|
|
created_blocks: result.change_report.created_blocks,
|
|
|
|
|
|
updated_blocks: result.change_report.updated_blocks,
|
|
|
|
|
|
moved_blocks: result.change_report.moved_blocks,
|
|
|
|
|
|
removed_blocks: result.change_report.removed_blocks,
|
|
|
|
|
|
notes: result.change_report.notes,
|
|
|
|
|
|
},
|
|
|
|
|
|
None,
|
|
|
|
|
|
None,
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn editor_session_demo(markdown: &str) -> CliResult<EditorCliOutput> {
|
|
|
|
|
|
let document = import_markdown(markdown).map_err(map_editor_error)?;
|
|
|
|
|
|
let bootstrap = if document.blocks().is_empty() {
|
|
|
|
|
|
mnote_editor_core::DocumentModel::new(vec![DocumentBlock::new(
|
|
|
|
|
|
"demo_root",
|
|
|
|
|
|
BlockType::Paragraph,
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_text("demo root")])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut session = EditorSession::new(bootstrap);
|
|
|
|
|
|
let primary_block_id = session
|
|
|
|
|
|
.document()
|
|
|
|
|
|
.blocks()
|
|
|
|
|
|
.first()
|
|
|
|
|
|
.map(|block| block.id.clone())
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("session demo 缺少可编辑块"))?;
|
|
|
|
|
|
let original_text = session
|
|
|
|
|
|
.document()
|
|
|
|
|
|
.block(&primary_block_id)
|
|
|
|
|
|
.map(|block| block.content.text.clone())
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
let next_text = if original_text.trim().is_empty() {
|
|
|
|
|
|
"demo edited".to_string()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("{original_text} [edited]")
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
session
|
|
|
|
|
|
.apply_command(EditorCommand::ReplaceBlock {
|
|
|
|
|
|
block_id: primary_block_id.clone(),
|
|
|
|
|
|
text: next_text,
|
|
|
|
|
|
})
|
|
|
|
|
|
.map_err(map_editor_error)?;
|
|
|
|
|
|
session
|
|
|
|
|
|
.apply_command(EditorCommand::InsertBlockAfter {
|
|
|
|
|
|
after_block_id: Some(primary_block_id),
|
|
|
|
|
|
block: DocumentBlock::new("cli_demo_block", BlockType::Paragraph)
|
|
|
|
|
|
.with_text("CLI demo block"),
|
|
|
|
|
|
})
|
|
|
|
|
|
.map_err(map_editor_error)?;
|
|
|
|
|
|
|
|
|
|
|
|
let undo_applied = session.undo();
|
|
|
|
|
|
let redo_applied = session.redo();
|
|
|
|
|
|
let projection = VisibilitySnapshot::derive(session.document());
|
|
|
|
|
|
Ok(build_editor_output(
|
|
|
|
|
|
"session_demo",
|
|
|
|
|
|
session.document().blocks().len(),
|
|
|
|
|
|
export_markdown(session.document()),
|
|
|
|
|
|
&projection,
|
|
|
|
|
|
Vec::new(),
|
|
|
|
|
|
EditorChangeReportOutput::default(),
|
|
|
|
|
|
Some(undo_applied),
|
|
|
|
|
|
Some(redo_applied),
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn render_editor_plain_output(output: &EditorCliOutput) -> String {
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"entrypoint={} operation={} block_count={} visible_blocks={} audit_count={} updated_blocks={} undo_applied={} redo_applied={}",
|
|
|
|
|
|
output.entrypoint,
|
|
|
|
|
|
output.operation,
|
|
|
|
|
|
output.block_count,
|
|
|
|
|
|
output.visible_block_ids.join(","),
|
|
|
|
|
|
output.audit.len(),
|
|
|
|
|
|
output.change_report.updated_blocks.join(","),
|
|
|
|
|
|
output.undo_applied.unwrap_or(false),
|
|
|
|
|
|
output.redo_applied.unwrap_or(false),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_editor_output(
|
|
|
|
|
|
operation: &'static str,
|
|
|
|
|
|
block_count: usize,
|
|
|
|
|
|
output_markdown: String,
|
|
|
|
|
|
projection: &VisibilitySnapshot,
|
|
|
|
|
|
audit: Vec<EditorAuditItem>,
|
|
|
|
|
|
change_report: EditorChangeReportOutput,
|
|
|
|
|
|
undo_applied: Option<bool>,
|
|
|
|
|
|
redo_applied: Option<bool>,
|
|
|
|
|
|
) -> EditorCliOutput {
|
|
|
|
|
|
EditorCliOutput {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
entrypoint: "mnote-cli",
|
|
|
|
|
|
operation,
|
|
|
|
|
|
block_count,
|
|
|
|
|
|
output_markdown,
|
|
|
|
|
|
visible_block_ids: projection.visible_block_ids.clone(),
|
|
|
|
|
|
outline: projection
|
|
|
|
|
|
.outline
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|entry| EditorOutlineItem {
|
|
|
|
|
|
block_id: entry.block_id.clone(),
|
|
|
|
|
|
level: entry.level,
|
|
|
|
|
|
title: entry.title.clone(),
|
|
|
|
|
|
depth: entry.depth,
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
audit,
|
|
|
|
|
|
change_report,
|
|
|
|
|
|
undo_applied,
|
|
|
|
|
|
redo_applied,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
pub fn render_plain_output(output: &CliJsonOutput) -> String {
|
|
|
|
|
|
match &output.operation {
|
|
|
|
|
|
CliOperationOutput::Command {
|
|
|
|
|
|
name,
|
|
|
|
|
|
command_id,
|
|
|
|
|
|
transport,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
..
|
|
|
|
|
|
} => format!(
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"domain={} action={} kind=command name={} command_id={} function={} executed={} request_id={} trace_id={}",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
output.domain,
|
|
|
|
|
|
output.action,
|
|
|
|
|
|
name,
|
|
|
|
|
|
command_id,
|
|
|
|
|
|
transport.function_name,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution.is_some(),
|
2026-04-15 20:01:12 +08:00
|
|
|
|
output.context.request_id,
|
|
|
|
|
|
output.context.trace_id,
|
|
|
|
|
|
),
|
|
|
|
|
|
CliOperationOutput::Query {
|
2026-04-16 15:24:37 +08:00
|
|
|
|
name,
|
|
|
|
|
|
transport,
|
|
|
|
|
|
execution,
|
|
|
|
|
|
..
|
2026-04-15 20:01:12 +08:00
|
|
|
|
} => format!(
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"domain={} action={} kind=query name={} function={} executed={} request_id={} trace_id={}",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
output.domain,
|
|
|
|
|
|
output.action,
|
|
|
|
|
|
name,
|
|
|
|
|
|
transport.function_name,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution.is_some(),
|
2026-04-15 20:01:12 +08:00
|
|
|
|
output.context.request_id,
|
|
|
|
|
|
output.context.trace_id,
|
|
|
|
|
|
),
|
|
|
|
|
|
CliOperationOutput::Tool {
|
|
|
|
|
|
tool_name,
|
|
|
|
|
|
invocation_kind,
|
|
|
|
|
|
execution_mode,
|
|
|
|
|
|
toolset_id,
|
|
|
|
|
|
effect,
|
|
|
|
|
|
requires_confirmation,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
..
|
|
|
|
|
|
} => format!(
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"domain={} action={} kind=tool tool={} invocation_kind={} execution_mode={} toolset={} effect={} requires_confirmation={} executed={} request_id={} trace_id={}",
|
2026-04-15 20:01:12 +08:00
|
|
|
|
output.domain,
|
|
|
|
|
|
output.action,
|
|
|
|
|
|
tool_name,
|
|
|
|
|
|
invocation_kind,
|
|
|
|
|
|
execution_mode,
|
|
|
|
|
|
toolset_id,
|
|
|
|
|
|
effect,
|
|
|
|
|
|
requires_confirmation,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution.is_some(),
|
2026-04-15 20:01:12 +08:00
|
|
|
|
output.context.request_id,
|
|
|
|
|
|
output.context.trace_id,
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_page_get(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
page_id: &str,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, workspace_id, "page", "get", page_id);
|
|
|
|
|
|
let query = QueryEnvelope {
|
|
|
|
|
|
name: "documents.content.get".into(),
|
|
|
|
|
|
payload: GetPageContent {
|
|
|
|
|
|
page_id: page_id.into(),
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_query_request(&bridge, &query).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_query_output(
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"get",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
query.name,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_query".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": page_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_page_title(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
page_id: &str,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
title: &str,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, workspace_id, "page", "title", page_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "documents.title.update".into(),
|
|
|
|
|
|
command_id: build_command_id("page", "title", page_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
page_id: Some(page_id.into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: UpdatePageTitle {
|
|
|
|
|
|
page_id: page_id.into(),
|
|
|
|
|
|
title: title.into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase2-cli".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"title",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"title": title,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": page_id,
|
|
|
|
|
|
"title": title,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_page_save(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
page_id: &str,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
revision: Option<u64>,
|
|
|
|
|
|
content_json: &str,
|
|
|
|
|
|
conflict_detection_key: Option<&str>,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let content_value = parse_json_value(content_json, "content_json")?;
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, workspace_id, "page", "save", page_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "documents.save".into(),
|
|
|
|
|
|
command_id: build_command_id("page", "save", page_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
page_id: Some(page_id.into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: SavePageContent {
|
|
|
|
|
|
page_id: page_id.into(),
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
revision,
|
|
|
|
|
|
content_json: content_json.into(),
|
|
|
|
|
|
conflict_detection_key: conflict_detection_key.map(|value| value.into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase2-cli".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"save",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"revision": revision,
|
|
|
|
|
|
"content": content_value,
|
|
|
|
|
|
"conflictDetectionKey": conflict_detection_key,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": page_id,
|
|
|
|
|
|
"content": content_value,
|
|
|
|
|
|
"expectedRevision": revision,
|
|
|
|
|
|
"conflictDetectionKey": conflict_detection_key,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:24:37 +08:00
|
|
|
|
pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let content_value = parse_json_value(args.content_json, "content_json")?;
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, Some(args.workspace_id), "page", "create", args.page_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "documents.create".into(),
|
|
|
|
|
|
command_id: build_command_id("page", "create", args.page_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: Some(args.workspace_id.into()),
|
|
|
|
|
|
page_id: Some(args.page_id.into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: CreateDocumentPage {
|
|
|
|
|
|
page_id: args.page_id.into(),
|
|
|
|
|
|
parent_page_id: args.parent_id.map(|value| value.into()),
|
|
|
|
|
|
title: args.title.into(),
|
|
|
|
|
|
workspace_seed_id: args.workspace_id.into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase8-cli".into(), "task-048".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"create",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"pageId": args.page_id,
|
|
|
|
|
|
"workspaceId": args.workspace_id,
|
|
|
|
|
|
"parentId": args.parent_id,
|
|
|
|
|
|
"title": args.title,
|
|
|
|
|
|
"accessScope": args.access_scope,
|
|
|
|
|
|
"content": content_value,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": args.page_id,
|
|
|
|
|
|
"workspaceId": args.workspace_id,
|
|
|
|
|
|
"parentId": args.parent_id,
|
|
|
|
|
|
"title": args.title,
|
|
|
|
|
|
"accessScope": args.access_scope,
|
|
|
|
|
|
"content": content_value,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "move", args.page_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "documents.move".into(),
|
|
|
|
|
|
command_id: build_command_id("page", "move", args.page_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: args.workspace_id.map(|value| value.into()),
|
|
|
|
|
|
page_id: Some(args.page_id.into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: MoveDocumentPage {
|
|
|
|
|
|
page_id: args.page_id.into(),
|
|
|
|
|
|
parent_page_id: args.parent_id.map(|value| value.into()),
|
|
|
|
|
|
sort_order: args.sort_order,
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase8-cli".into(), "task-048".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"move",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"pageId": args.page_id,
|
|
|
|
|
|
"workspaceId": args.workspace_id,
|
|
|
|
|
|
"parentId": args.parent_id,
|
|
|
|
|
|
"sortOrder": args.sort_order,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": args.page_id,
|
|
|
|
|
|
"parentId": args.parent_id,
|
|
|
|
|
|
"sortOrder": args.sort_order,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "delete", args.page_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "documents.delete".into(),
|
|
|
|
|
|
command_id: build_command_id("page", "delete", args.page_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: args.workspace_id.map(|value| value.into()),
|
|
|
|
|
|
page_id: Some(args.page_id.into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: DeleteDocumentPage {
|
|
|
|
|
|
page_id: args.page_id.into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase8-cli".into(), "task-048".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"delete",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"pageId": args.page_id,
|
|
|
|
|
|
"workspaceId": args.workspace_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": args.page_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "restore", args.page_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "documents.restore".into(),
|
|
|
|
|
|
command_id: build_command_id("page", "restore", args.page_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: args.workspace_id.map(|value| value.into()),
|
|
|
|
|
|
page_id: Some(args.page_id.into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: RestoreDocumentPage {
|
|
|
|
|
|
page_id: args.page_id.into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase8-cli".into(), "task-048".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"restore",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"pageId": args.page_id,
|
|
|
|
|
|
"workspaceId": args.workspace_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": args.page_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
pub fn plan_block_insert(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
|
page_id: &str,
|
|
|
|
|
|
content: &str,
|
|
|
|
|
|
block_type: &str,
|
|
|
|
|
|
parent_block_id: Option<&str>,
|
|
|
|
|
|
prev_block_id: Option<&str>,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, Some(workspace_id), "block", "insert", page_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "insert_block".into(),
|
|
|
|
|
|
command_id: build_command_id("block", "insert", page_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: Some(workspace_id.into()),
|
|
|
|
|
|
page_id: Some(page_id.into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
2026-04-16 15:24:37 +08:00
|
|
|
|
payload: core_protocol::InsertBlock {
|
2026-04-15 20:01:12 +08:00
|
|
|
|
page_id: page_id.into(),
|
|
|
|
|
|
block_type: block_type.into(),
|
|
|
|
|
|
content: content.into(),
|
|
|
|
|
|
parent_block_id: parent_block_id.map(|value| value.into()),
|
|
|
|
|
|
prev_block_id: prev_block_id.map(|value| value.into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase2-cli".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"block",
|
|
|
|
|
|
"insert",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"content": content,
|
|
|
|
|
|
"blockType": block_type,
|
|
|
|
|
|
"parentBlockId": parent_block_id,
|
|
|
|
|
|
"prevBlockId": prev_block_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"content": content,
|
|
|
|
|
|
"blockType": block_type,
|
|
|
|
|
|
"parentBlockId": parent_block_id,
|
|
|
|
|
|
"prevBlockId": prev_block_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_block_patch(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
page_id: &str,
|
|
|
|
|
|
block_id: &str,
|
|
|
|
|
|
revision: Option<u64>,
|
|
|
|
|
|
snapshot_json: &str,
|
|
|
|
|
|
conflict_detection_key: Option<&str>,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let snapshot_value = parse_json_value(snapshot_json, "snapshot_json")?;
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, workspace_id, "block", "patch", block_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "blocks.patch".into(),
|
|
|
|
|
|
command_id: build_command_id("block", "patch", block_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
page_id: Some(page_id.into()),
|
|
|
|
|
|
block_id: Some(block_id.into()),
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: PatchPageBlock {
|
|
|
|
|
|
page_id: page_id.into(),
|
|
|
|
|
|
block_id: block_id.into(),
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
revision,
|
|
|
|
|
|
block_snapshot_json: snapshot_json.into(),
|
|
|
|
|
|
conflict_detection_key: conflict_detection_key.map(|value| value.into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase2-cli".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"block",
|
|
|
|
|
|
"patch",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"blockId": block_id,
|
|
|
|
|
|
"revision": revision,
|
|
|
|
|
|
"snapshot": snapshot_value,
|
|
|
|
|
|
"conflictDetectionKey": conflict_detection_key,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"blockId": block_id,
|
|
|
|
|
|
"snapshot": snapshot_value,
|
|
|
|
|
|
"expectedRevision": revision,
|
|
|
|
|
|
"conflictDetectionKey": conflict_detection_key,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:24:37 +08:00
|
|
|
|
pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, None, "block", "move", args.block_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "blocks.move".into(),
|
|
|
|
|
|
command_id: build_command_id("block", "move", args.block_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: None,
|
|
|
|
|
|
page_id: Some(args.target_document_id.into()),
|
|
|
|
|
|
block_id: Some(args.block_id.into()),
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: core_protocol::MoveBlock {
|
|
|
|
|
|
block_id: args.block_id.into(),
|
|
|
|
|
|
new_parent_block_id: None,
|
|
|
|
|
|
new_page_id: Some(args.target_document_id.into()),
|
|
|
|
|
|
prev_block_id: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase8-cli".into(), "task-048".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"block",
|
|
|
|
|
|
"move",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"sourceDocumentId": args.source_document_id,
|
|
|
|
|
|
"blockId": args.block_id,
|
|
|
|
|
|
"targetDocumentId": args.target_document_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"id": args.block_id,
|
|
|
|
|
|
"sourceDocumentId": args.source_document_id,
|
|
|
|
|
|
"targetDocumentId": args.target_document_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, None, "block", "embed", args.block_id);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "blocks.embed".into(),
|
|
|
|
|
|
command_id: build_command_id("block", "embed", args.block_id),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: None,
|
|
|
|
|
|
page_id: Some(args.target_document_id.into()),
|
|
|
|
|
|
block_id: Some(args.block_id.into()),
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: core_protocol::EmbedBlock {
|
|
|
|
|
|
source_document_id: args.source_document_id.into(),
|
|
|
|
|
|
source_block_id: args.block_id.into(),
|
|
|
|
|
|
target_document_id: args.target_document_id.into(),
|
|
|
|
|
|
target_block_id: args.target_block_id.map(|value| value.into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase8-cli".into(), "task-048".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"block",
|
|
|
|
|
|
"embed",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"sourceDocumentId": args.source_document_id,
|
|
|
|
|
|
"blockId": args.block_id,
|
|
|
|
|
|
"targetDocumentId": args.target_document_id,
|
|
|
|
|
|
"targetBlockId": args.target_block_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"sourceDocumentId": args.source_document_id,
|
|
|
|
|
|
"blockId": args.block_id,
|
|
|
|
|
|
"targetDocumentId": args.target_document_id,
|
|
|
|
|
|
"targetBlockId": args.target_block_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
pub fn plan_search_documents(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
query: &str,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
limit: u32,
|
|
|
|
|
|
cursor: Option<&str>,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
2026-04-16 15:24:37 +08:00
|
|
|
|
let workspace_id =
|
|
|
|
|
|
workspace_id.ok_or_else(|| CliError::validation("search documents 缺少 --workspace-id"))?;
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, Some(workspace_id), "search", "documents", query);
|
2026-04-15 20:01:12 +08:00
|
|
|
|
let envelope = QueryEnvelope {
|
2026-04-16 15:24:37 +08:00
|
|
|
|
name: "search.documents".into(),
|
|
|
|
|
|
payload: SearchDocuments {
|
2026-04-15 20:01:12 +08:00
|
|
|
|
query: query.into(),
|
2026-04-16 15:24:37 +08:00
|
|
|
|
workspace_id: workspace_id.into(),
|
|
|
|
|
|
page_id: None,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
pagination: Pagination {
|
|
|
|
|
|
limit,
|
|
|
|
|
|
cursor: cursor.map(|value| value.into()),
|
|
|
|
|
|
},
|
2026-04-16 15:24:37 +08:00
|
|
|
|
title_only: false,
|
|
|
|
|
|
exact: false,
|
|
|
|
|
|
include_ocr: false,
|
|
|
|
|
|
time_range: "any".into(),
|
|
|
|
|
|
time_field: "updated".into(),
|
|
|
|
|
|
custom_range_from: None,
|
|
|
|
|
|
custom_range_to: None,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_query_output(
|
|
|
|
|
|
"search",
|
|
|
|
|
|
"documents",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
envelope.name,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"query": query,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"pagination": {
|
|
|
|
|
|
"limit": limit,
|
|
|
|
|
|
"cursor": cursor,
|
|
|
|
|
|
},
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"filters": {
|
|
|
|
|
|
"titleOnly": false,
|
|
|
|
|
|
"exact": false,
|
|
|
|
|
|
"includeOcr": false,
|
|
|
|
|
|
"timeRange": "any",
|
|
|
|
|
|
"timeField": "updated",
|
|
|
|
|
|
},
|
2026-04-15 20:01:12 +08:00
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_query".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"query": query,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"pageId": Value::Null,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
"limit": limit,
|
|
|
|
|
|
"cursor": cursor,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
"titleOnly": false,
|
|
|
|
|
|
"exact": false,
|
|
|
|
|
|
"includeOcr": false,
|
|
|
|
|
|
"timeRange": "any",
|
|
|
|
|
|
"timeField": "updated",
|
|
|
|
|
|
"customRangeFrom": Value::Null,
|
|
|
|
|
|
"customRangeTo": Value::Null,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_search_blocks(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
query: &str,
|
|
|
|
|
|
page_id: Option<&str>,
|
|
|
|
|
|
limit: u32,
|
|
|
|
|
|
cursor: Option<&str>,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, None, "search", "blocks", query);
|
|
|
|
|
|
let envelope = QueryEnvelope {
|
|
|
|
|
|
name: "search_blocks".into(),
|
|
|
|
|
|
payload: SearchBlocks {
|
|
|
|
|
|
query: query.into(),
|
|
|
|
|
|
page_id: page_id.map(|value| value.into()),
|
|
|
|
|
|
pagination: Pagination {
|
|
|
|
|
|
limit,
|
|
|
|
|
|
cursor: cursor.map(|value| value.into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_query_output(
|
|
|
|
|
|
"search",
|
|
|
|
|
|
"blocks",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
envelope.name,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"query": query,
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"pagination": {
|
|
|
|
|
|
"limit": limit,
|
|
|
|
|
|
"cursor": cursor,
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_query".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"query": query,
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"limit": limit,
|
|
|
|
|
|
"cursor": cursor,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, Some(workspace_id), "sidebar", "dataset", workspace_id);
|
|
|
|
|
|
let envelope = QueryEnvelope {
|
|
|
|
|
|
name: "sidebar.dataset.list".into(),
|
|
|
|
|
|
payload: ListSidebarDataset {
|
|
|
|
|
|
workspace_id: workspace_id.into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_query_output(
|
|
|
|
|
|
"sidebar",
|
|
|
|
|
|
"dataset",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
envelope.name,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_query".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_mindmap_get(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
document_id: &str,
|
|
|
|
|
|
mindmap_id: &str,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let bridge = build_bridge_context(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
"mindmap",
|
|
|
|
|
|
"get",
|
|
|
|
|
|
&format!("{document_id}_{mindmap_id}"),
|
|
|
|
|
|
);
|
|
|
|
|
|
let envelope = QueryEnvelope {
|
|
|
|
|
|
name: "mindmaps.get".into(),
|
|
|
|
|
|
payload: GetMindmap {
|
|
|
|
|
|
document_id: document_id.into(),
|
|
|
|
|
|
mindmap_id: mindmap_id.into(),
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_query_output(
|
|
|
|
|
|
"mindmap",
|
|
|
|
|
|
"get",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
envelope.name,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
|
"mindmapId": mindmap_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_query".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"docId": document_id,
|
|
|
|
|
|
"mindmapId": mindmap_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_mindmap_put(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
document_id: &str,
|
|
|
|
|
|
mindmap_id: &str,
|
|
|
|
|
|
data_json: &str,
|
|
|
|
|
|
create_only: bool,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let data = parse_json_value(data_json, "data_json")?;
|
|
|
|
|
|
serde_json::from_value::<MindmapTreeNode>(data.clone())
|
|
|
|
|
|
.map_err(|error| CliError::validation(format!("data_json 不是合法导图树: {error}")))?;
|
|
|
|
|
|
let bridge = build_bridge_context(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
"mindmap",
|
|
|
|
|
|
"put",
|
|
|
|
|
|
&format!("{document_id}_{mindmap_id}"),
|
|
|
|
|
|
);
|
|
|
|
|
|
let command = CommandEnvelope {
|
|
|
|
|
|
name: "mindmaps.put".into(),
|
|
|
|
|
|
command_id: build_command_id("mindmap", "put", &format!("{document_id}_{mindmap_id}")),
|
|
|
|
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
|
|
|
|
|
actor: build_actor_payload(ctx),
|
|
|
|
|
|
source: build_source_payload(),
|
|
|
|
|
|
target: Some(TargetRef {
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
page_id: Some(document_id.into()),
|
|
|
|
|
|
block_id: Some(mindmap_id.into()),
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload: PutMindmap {
|
|
|
|
|
|
document_id: document_id.into(),
|
|
|
|
|
|
mindmap_id: mindmap_id.into(),
|
|
|
|
|
|
data_json: serde_json::to_string(&data).map_err(|error| {
|
|
|
|
|
|
CliError::validation(format!("mindmap data_json 序列化失败: {error}"))
|
|
|
|
|
|
})?,
|
|
|
|
|
|
create_only,
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["phase6-cli".into(), "task-032".into()],
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
};
|
|
|
|
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
|
|
|
|
|
Ok(build_command_output(
|
|
|
|
|
|
"mindmap",
|
|
|
|
|
|
"put",
|
|
|
|
|
|
&bridge,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
command.name,
|
|
|
|
|
|
command.command_id,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
|
"mindmapId": mindmap_id,
|
|
|
|
|
|
"createOnly": create_only,
|
|
|
|
|
|
"data": data,
|
|
|
|
|
|
}),
|
|
|
|
|
|
CliTransportPlan {
|
|
|
|
|
|
kind: "convex_mutation".into(),
|
|
|
|
|
|
function_name: request.function_name,
|
|
|
|
|
|
payload_json: request.payload_json,
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"docId": document_id,
|
|
|
|
|
|
"mindmapId": mindmap_id,
|
|
|
|
|
|
"data": data,
|
|
|
|
|
|
"createOnly": create_only,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_mindmap_op(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
document_id: &str,
|
|
|
|
|
|
mindmap_id: &str,
|
|
|
|
|
|
ops_json: &str,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let ops_value = parse_json_value(ops_json, "ops_json")?;
|
2026-04-16 15:24:37 +08:00
|
|
|
|
let ops: Vec<MindmapOp> = serde_json::from_value(ops_value.clone())
|
|
|
|
|
|
.map_err(|error| CliError::validation(format!("ops_json 不是合法 MindmapOp[]: {error}")))?;
|
2026-04-15 20:01:12 +08:00
|
|
|
|
if ops.is_empty() {
|
|
|
|
|
|
return Err(CliError::validation("ops_json 不能为空数组"));
|
|
|
|
|
|
}
|
|
|
|
|
|
let bridge = build_bridge_context(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
"mindmap",
|
|
|
|
|
|
"op",
|
|
|
|
|
|
&format!("{document_id}_{mindmap_id}"),
|
|
|
|
|
|
);
|
|
|
|
|
|
let spec = default_tool_registry()
|
|
|
|
|
|
.tool("mindmap_apply_ops")
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("未注册 tool: mindmap_apply_ops"))?;
|
|
|
|
|
|
Ok(CliJsonOutput {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
entrypoint: "mnote-cli",
|
|
|
|
|
|
domain: "mindmap".into(),
|
|
|
|
|
|
action: "op".into(),
|
|
|
|
|
|
context: build_output_context(&bridge, ctx),
|
|
|
|
|
|
operation: CliOperationOutput::Tool {
|
|
|
|
|
|
tool_name: "mindmap_apply_ops".into(),
|
|
|
|
|
|
invocation_kind: invocation_kind_label(&InvocationKind::Command).into(),
|
|
|
|
|
|
execution_mode: core_protocol::tool_mode_label(&ToolExecutionMode::Result).into(),
|
|
|
|
|
|
toolset_id: spec.toolset_id.into(),
|
|
|
|
|
|
effect: tool_effect_label(&spec.effect).into(),
|
|
|
|
|
|
requires_confirmation: spec.requires_confirmation,
|
|
|
|
|
|
normalized_input: json!({
|
|
|
|
|
|
"toolName": "mindmap_apply_ops",
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
|
"mindmapId": mindmap_id,
|
|
|
|
|
|
"ops": ops_value,
|
|
|
|
|
|
"reason": ctx.reason,
|
|
|
|
|
|
"validateOnly": bridge.validate_only,
|
|
|
|
|
|
"dryRun": bridge.dry_run,
|
|
|
|
|
|
}),
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution: None,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn plan_tool_run(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
tool_name: &str,
|
|
|
|
|
|
invocation_kind: InvocationKind,
|
|
|
|
|
|
execution_mode: ToolExecutionMode,
|
|
|
|
|
|
args_json: &str,
|
|
|
|
|
|
) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let args_value = parse_json_value(args_json, "args_json")?;
|
|
|
|
|
|
let bridge = build_bridge_context(ctx, None, "tool", "run", tool_name);
|
|
|
|
|
|
let spec = default_tool_registry()
|
|
|
|
|
|
.tool(tool_name)
|
|
|
|
|
|
.ok_or_else(|| CliError::validation(format!("未知 tool: {tool_name}")))?;
|
|
|
|
|
|
if spec.invocation_kind != invocation_kind {
|
|
|
|
|
|
return Err(CliError::validation(format!(
|
|
|
|
|
|
"tool {tool_name} 仅支持 {},当前传入 {}",
|
|
|
|
|
|
invocation_kind_label(&spec.invocation_kind),
|
|
|
|
|
|
invocation_kind_label(&invocation_kind),
|
|
|
|
|
|
)));
|
|
|
|
|
|
}
|
|
|
|
|
|
let invocation = ToolInvocation {
|
|
|
|
|
|
tool: tool_name.into(),
|
2026-04-16 15:24:37 +08:00
|
|
|
|
kind: invocation_kind,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
mode: execution_mode,
|
|
|
|
|
|
args_json: args_json.into(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let operation = CliOperationOutput::Tool {
|
|
|
|
|
|
tool_name: invocation.tool,
|
|
|
|
|
|
invocation_kind: invocation_kind_label(&invocation.kind).into(),
|
|
|
|
|
|
execution_mode: core_protocol::tool_mode_label(&invocation.mode).into(),
|
|
|
|
|
|
toolset_id: spec.toolset_id.into(),
|
|
|
|
|
|
effect: tool_effect_label(&spec.effect).into(),
|
|
|
|
|
|
requires_confirmation: spec.requires_confirmation,
|
|
|
|
|
|
normalized_input: json!({
|
|
|
|
|
|
"toolName": tool_name,
|
|
|
|
|
|
"invocationKind": invocation_kind_label(&invocation.kind),
|
|
|
|
|
|
"executionMode": core_protocol::tool_mode_label(&invocation.mode),
|
|
|
|
|
|
"toolsetId": spec.toolset_id,
|
|
|
|
|
|
"effect": tool_effect_label(&spec.effect),
|
|
|
|
|
|
"requiresConfirmation": spec.requires_confirmation,
|
|
|
|
|
|
"validateOnly": bridge.validate_only,
|
|
|
|
|
|
"dryRun": bridge.dry_run,
|
|
|
|
|
|
"args": args_value,
|
|
|
|
|
|
}),
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution: None,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
};
|
|
|
|
|
|
Ok(CliJsonOutput {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
entrypoint: "mnote-cli",
|
|
|
|
|
|
domain: "tool".into(),
|
|
|
|
|
|
action: "run".into(),
|
|
|
|
|
|
context: build_output_context(&bridge, ctx),
|
|
|
|
|
|
operation,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:24:37 +08:00
|
|
|
|
pub fn execute_output(output: &CliJsonOutput, ctx: &CliContext) -> CliResult<CliJsonOutput> {
|
|
|
|
|
|
let mut next = output.clone();
|
|
|
|
|
|
match &mut next.operation {
|
|
|
|
|
|
CliOperationOutput::Query {
|
|
|
|
|
|
name,
|
|
|
|
|
|
transport,
|
|
|
|
|
|
execution,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
*execution = Some(execute_query(name, transport, &next.context, ctx)?);
|
|
|
|
|
|
}
|
|
|
|
|
|
CliOperationOutput::Command {
|
|
|
|
|
|
name,
|
|
|
|
|
|
normalized_input,
|
|
|
|
|
|
transport,
|
|
|
|
|
|
execution,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
*execution = Some(execute_command(
|
|
|
|
|
|
name,
|
|
|
|
|
|
normalized_input,
|
|
|
|
|
|
transport,
|
|
|
|
|
|
&next.context,
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
)?);
|
|
|
|
|
|
}
|
|
|
|
|
|
CliOperationOutput::Tool {
|
|
|
|
|
|
tool_name,
|
|
|
|
|
|
invocation_kind,
|
|
|
|
|
|
execution_mode,
|
|
|
|
|
|
normalized_input,
|
|
|
|
|
|
execution,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
*execution = Some(execute_tool(
|
|
|
|
|
|
tool_name,
|
|
|
|
|
|
invocation_kind,
|
|
|
|
|
|
execution_mode,
|
|
|
|
|
|
normalized_input,
|
|
|
|
|
|
&next.context,
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
)?);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(next)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_query(
|
|
|
|
|
|
name: &str,
|
|
|
|
|
|
transport: &CliTransportPlan,
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
match name {
|
|
|
|
|
|
"documents.content.get"
|
|
|
|
|
|
| "documents.meta.get"
|
|
|
|
|
|
| "mindmaps.get"
|
|
|
|
|
|
| "sidebar.dataset.list" => execute_convex_query(transport, cli_ctx),
|
|
|
|
|
|
"search.documents" => execute_search_documents(transport, context, cli_ctx),
|
|
|
|
|
|
other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_command(
|
|
|
|
|
|
name: &str,
|
|
|
|
|
|
normalized_input: &Value,
|
|
|
|
|
|
transport: &CliTransportPlan,
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
match name {
|
|
|
|
|
|
"documents.create"
|
|
|
|
|
|
| "documents.move"
|
|
|
|
|
|
| "documents.delete"
|
|
|
|
|
|
| "documents.restore"
|
|
|
|
|
|
| "documents.title.update"
|
|
|
|
|
|
| "documents.save"
|
|
|
|
|
|
| "mindmaps.put" => execute_convex_mutation(transport, cli_ctx),
|
|
|
|
|
|
"insert_block" => execute_block_insert(normalized_input, context, cli_ctx),
|
|
|
|
|
|
"blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx),
|
|
|
|
|
|
"blocks.move" => execute_block_move(normalized_input, cli_ctx),
|
|
|
|
|
|
"blocks.embed" => execute_block_embed(normalized_input, cli_ctx),
|
|
|
|
|
|
other => Err(CliError::validation(format!(
|
|
|
|
|
|
"暂不支持执行 command: {other}"
|
|
|
|
|
|
))),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_tool(
|
|
|
|
|
|
tool_name: &str,
|
|
|
|
|
|
invocation_kind: &str,
|
|
|
|
|
|
execution_mode: &str,
|
|
|
|
|
|
normalized_input: &Value,
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
if execution_mode != "result" {
|
|
|
|
|
|
return Err(CliError::validation(
|
|
|
|
|
|
"tool --execute 目前只支持 mode=result",
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
let args = normalized_input.get("args").cloned().unwrap_or(Value::Null);
|
|
|
|
|
|
let runtime_args = merge_tool_args(tool_name, normalized_input, &args, context);
|
|
|
|
|
|
let target = build_tool_target(tool_name, normalized_input, context)?;
|
|
|
|
|
|
let runtime_context = build_runtime_context(context, cli_ctx);
|
|
|
|
|
|
let data = load_tool_data(tool_name, &runtime_args, target.as_ref(), cli_ctx)?;
|
|
|
|
|
|
execute_runtime_query(RuntimeInput::Tool {
|
|
|
|
|
|
context: runtime_context,
|
|
|
|
|
|
tool: RuntimeToolInvocationWire {
|
|
|
|
|
|
tool: tool_name.into(),
|
|
|
|
|
|
kind: invocation_kind.into(),
|
|
|
|
|
|
mode: Some("result".into()),
|
|
|
|
|
|
args_json: runtime_args,
|
|
|
|
|
|
target,
|
|
|
|
|
|
reason: cli_ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["mnote-cli-execute".into()],
|
|
|
|
|
|
},
|
|
|
|
|
|
data: Some(data),
|
|
|
|
|
|
})
|
|
|
|
|
|
.map_err(|error| CliError::transport(error.message))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn merge_tool_args(
|
|
|
|
|
|
tool_name: &str,
|
|
|
|
|
|
normalized_input: &Value,
|
|
|
|
|
|
args: &Value,
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
) -> Value {
|
|
|
|
|
|
let mut merged = args.as_object().cloned().unwrap_or_default();
|
|
|
|
|
|
if let Some(workspace_id) = context.workspace_id.as_ref() {
|
|
|
|
|
|
merged
|
|
|
|
|
|
.entry("workspaceId")
|
|
|
|
|
|
.or_insert_with(|| json!(workspace_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(document_id) = normalized_input.get("documentId").cloned() {
|
|
|
|
|
|
merged.entry("documentId").or_insert(document_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(mindmap_id) = normalized_input.get("mindmapId").cloned() {
|
|
|
|
|
|
merged.entry("mindmapId").or_insert(mindmap_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(ops) = normalized_input.get("ops").cloned() {
|
|
|
|
|
|
merged.entry("ops").or_insert(ops);
|
|
|
|
|
|
}
|
|
|
|
|
|
if tool_name == "mindmap_apply_ops" {
|
|
|
|
|
|
if let Some(page_id) = normalized_input.get("documentId").cloned() {
|
|
|
|
|
|
merged.entry("pageId").or_insert(page_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Value::Object(merged)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_search_documents(
|
|
|
|
|
|
transport: &CliTransportPlan,
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
let workspace_id = transport
|
|
|
|
|
|
.args_json
|
|
|
|
|
|
.get("workspaceId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("search documents 缺少 workspaceId"))?;
|
|
|
|
|
|
let documents = execute_convex_query_raw(
|
|
|
|
|
|
"documents:listSearchDataByWorkspace",
|
|
|
|
|
|
json!({ "workspaceId": workspace_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let mindmaps = execute_convex_query_raw(
|
|
|
|
|
|
"mindmaps:listByWorkspace",
|
|
|
|
|
|
json!({ "workspaceId": workspace_id, "includeDeleted": false }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
let tables = execute_convex_query_raw(
|
|
|
|
|
|
"tables:listByWorkspaceForSearch",
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"userId": context.actor_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"includeArchived": false,
|
|
|
|
|
|
"limit": 3000,
|
|
|
|
|
|
}),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
let table_rows = execute_convex_query_raw(
|
|
|
|
|
|
"tables:listRowsByWorkspaceForSearch",
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"userId": context.actor_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"limit": 8000,
|
|
|
|
|
|
}),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
let assets = execute_convex_query_raw(
|
|
|
|
|
|
"mediaAssets:listSearchDataByWorkspace",
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"userId": context.actor_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"includeDeleted": false,
|
|
|
|
|
|
"limit": 5000,
|
|
|
|
|
|
}),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
let dataset = json!({
|
|
|
|
|
|
"documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)),
|
|
|
|
|
|
"title": item.get("title").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"rawText": item.get("raw_text").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"createdAt": item.get("created_at").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"data": item.get("data").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"title": item.get("title").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"tableId": item.get("table_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"assetType": item.get("asset_type").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"fileName": item.get("file_name").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
});
|
|
|
|
|
|
execute_runtime_query(RuntimeInput::Query {
|
|
|
|
|
|
context: build_runtime_context(context, cli_ctx),
|
|
|
|
|
|
query: RuntimeQueryEnvelopeWire {
|
|
|
|
|
|
name: "search.documents".into(),
|
|
|
|
|
|
payload: transport.args_json.clone(),
|
|
|
|
|
|
},
|
|
|
|
|
|
data: Some(dataset),
|
|
|
|
|
|
})
|
|
|
|
|
|
.map_err(|error| CliError::transport(error.message))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_block_insert(
|
|
|
|
|
|
normalized_input: &Value,
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
let page_id = required_str(normalized_input, "pageId")?;
|
|
|
|
|
|
let workspace_id = required_str(normalized_input, "workspaceId")?;
|
|
|
|
|
|
let content = required_str(normalized_input, "content")?;
|
|
|
|
|
|
let block_type = normalized_input
|
|
|
|
|
|
.get("blockType")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.unwrap_or("paragraph");
|
|
|
|
|
|
let before_block_id = None::<String>;
|
|
|
|
|
|
let after_block_id = normalized_input
|
|
|
|
|
|
.get("prevBlockId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string);
|
|
|
|
|
|
let data = execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?;
|
|
|
|
|
|
let block_specs = json!([{
|
|
|
|
|
|
"type": block_type,
|
|
|
|
|
|
"text": content,
|
|
|
|
|
|
}]);
|
|
|
|
|
|
let runtime_result = execute_runtime_query(RuntimeInput::Tool {
|
|
|
|
|
|
context: build_runtime_context(context, cli_ctx),
|
|
|
|
|
|
tool: RuntimeToolInvocationWire {
|
|
|
|
|
|
tool: "doc_insert_blocks".into(),
|
|
|
|
|
|
kind: "command".into(),
|
|
|
|
|
|
mode: Some("result".into()),
|
|
|
|
|
|
args_json: json!({
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"afterBlockId": after_block_id,
|
|
|
|
|
|
"beforeBlockId": before_block_id,
|
|
|
|
|
|
"blocks": block_specs,
|
|
|
|
|
|
}),
|
|
|
|
|
|
target: Some(RuntimeTargetWire {
|
|
|
|
|
|
workspace_id: Some(workspace_id.to_string()),
|
|
|
|
|
|
page_id: Some(page_id.to_string()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
reason: cli_ctx.reason.clone(),
|
|
|
|
|
|
refs: vec!["mnote-cli-execute".into()],
|
|
|
|
|
|
},
|
|
|
|
|
|
data: Some(data.clone()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.map_err(|error| CliError::transport(error.message))?;
|
|
|
|
|
|
let next_content = write_blocks_back(
|
|
|
|
|
|
&data,
|
|
|
|
|
|
runtime_result
|
|
|
|
|
|
.get("data")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or(Value::Array(vec![])),
|
|
|
|
|
|
);
|
|
|
|
|
|
let save_result = execute_convex_mutation_raw(
|
|
|
|
|
|
"documents:updateContent",
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": page_id,
|
|
|
|
|
|
"content": next_content,
|
|
|
|
|
|
"expectedRevision": data.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": data.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
Ok(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"pageId": page_id,
|
|
|
|
|
|
"inserted": runtime_result.get("inserted").cloned().unwrap_or(Value::Array(vec![])),
|
|
|
|
|
|
"revision": save_result.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": save_result.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"data": runtime_result.get("data").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_block_patch(
|
|
|
|
|
|
normalized_input: &Value,
|
|
|
|
|
|
transport: &CliTransportPlan,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
let page_id = required_str(normalized_input, "pageId")?;
|
|
|
|
|
|
let block_id = required_str(normalized_input, "blockId")?;
|
|
|
|
|
|
let snapshot = normalized_input
|
|
|
|
|
|
.get("snapshot")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("block patch 缺少 snapshot"))?;
|
|
|
|
|
|
let current =
|
|
|
|
|
|
execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?;
|
|
|
|
|
|
let next_blocks = replace_block_subtree(extract_blocks(¤t), block_id, &snapshot)?;
|
|
|
|
|
|
let args = json!({
|
|
|
|
|
|
"id": page_id,
|
|
|
|
|
|
"content": write_blocks_back(¤t, Value::Array(next_blocks)),
|
|
|
|
|
|
"expectedRevision": transport.args_json.get("expectedRevision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": transport.args_json.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
});
|
|
|
|
|
|
execute_convex_mutation_raw("documents:updateContent", args, cli_ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_block_move(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> {
|
|
|
|
|
|
let source_document_id = required_str(normalized_input, "sourceDocumentId")?;
|
|
|
|
|
|
let target_document_id = required_str(normalized_input, "targetDocumentId")?;
|
|
|
|
|
|
let block_id = required_str(normalized_input, "blockId")?;
|
|
|
|
|
|
|
|
|
|
|
|
let source = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getContent",
|
|
|
|
|
|
json!({ "id": source_document_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let target = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getContent",
|
|
|
|
|
|
json!({ "id": target_document_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let source_blocks = extract_blocks(&source);
|
|
|
|
|
|
let target_blocks = extract_blocks(&target);
|
|
|
|
|
|
let (removed, next_source_blocks) = remove_block_subtree(source_blocks, block_id)?;
|
|
|
|
|
|
let mut next_target_blocks = target_blocks;
|
|
|
|
|
|
next_target_blocks.push(removed);
|
|
|
|
|
|
let next_source_content = write_blocks_back(&source, Value::Array(next_source_blocks));
|
|
|
|
|
|
let next_target_content = write_blocks_back(&target, Value::Array(next_target_blocks));
|
|
|
|
|
|
let source_save = execute_convex_mutation_raw(
|
|
|
|
|
|
"documents:updateContent",
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": source_document_id,
|
|
|
|
|
|
"content": next_source_content,
|
|
|
|
|
|
"expectedRevision": source.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": source.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let target_save = execute_convex_mutation_raw(
|
|
|
|
|
|
"documents:updateContent",
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": target_document_id,
|
|
|
|
|
|
"content": next_target_content,
|
|
|
|
|
|
"expectedRevision": target.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": target.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
Ok(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"sourceRevision": source_save.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"targetRevision": target_save.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_block_embed(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> {
|
|
|
|
|
|
let source_document_id = required_str(normalized_input, "sourceDocumentId")?;
|
|
|
|
|
|
let target_document_id = required_str(normalized_input, "targetDocumentId")?;
|
|
|
|
|
|
let block_id = required_str(normalized_input, "blockId")?;
|
|
|
|
|
|
let target_block_id = normalized_input
|
|
|
|
|
|
.get("targetBlockId")
|
|
|
|
|
|
.and_then(Value::as_str);
|
|
|
|
|
|
|
|
|
|
|
|
let source_meta = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getMeta",
|
|
|
|
|
|
json!({ "id": source_document_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let target_content = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getContent",
|
|
|
|
|
|
json!({ "id": target_document_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let target_meta = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getMeta",
|
|
|
|
|
|
json!({ "id": target_document_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let mut target_blocks = extract_blocks(&target_content);
|
|
|
|
|
|
let reference_block = json!({
|
|
|
|
|
|
"id": format!("cli_embed_{}_{}", target_document_id, block_id),
|
|
|
|
|
|
"type": "blockReference",
|
|
|
|
|
|
"props": {
|
|
|
|
|
|
"sourceDocumentId": source_document_id,
|
|
|
|
|
|
"targetBlockId": block_id,
|
|
|
|
|
|
"display": "embed",
|
|
|
|
|
|
},
|
|
|
|
|
|
"content": [],
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
});
|
|
|
|
|
|
let anchor_id = target_block_id.map(str::to_string).or_else(|| {
|
|
|
|
|
|
target_meta
|
|
|
|
|
|
.get("embed_default_block_id")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
});
|
|
|
|
|
|
if let Some(anchor_id) = anchor_id {
|
|
|
|
|
|
if let Some(index) = find_block_index(&target_blocks, &anchor_id) {
|
|
|
|
|
|
target_blocks.insert(index + 1, reference_block.clone());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
target_blocks.push(reference_block.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
target_blocks.push(reference_block.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
let saved = execute_convex_mutation_raw(
|
|
|
|
|
|
"documents:updateContent",
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": target_document_id,
|
|
|
|
|
|
"content": write_blocks_back(&target_content, Value::Array(target_blocks)),
|
|
|
|
|
|
"expectedRevision": target_content.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": target_content.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
Ok(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"sourceTitle": source_meta.get("title").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"referenceBlockId": reference_block.get("id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"revision": saved.get("revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_tool_target(
|
|
|
|
|
|
tool_name: &str,
|
|
|
|
|
|
normalized_input: &Value,
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
) -> CliResult<Option<RuntimeTargetWire>> {
|
|
|
|
|
|
let args = normalized_input.get("args").cloned().unwrap_or(Value::Null);
|
|
|
|
|
|
let workspace_id = args
|
|
|
|
|
|
.get("workspaceId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
.or_else(|| context.workspace_id.clone());
|
|
|
|
|
|
let page_id = args
|
|
|
|
|
|
.get("documentId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
|
args.get("pageId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
});
|
|
|
|
|
|
let block_id = args
|
|
|
|
|
|
.get("mindmapId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
|
args.get("blockId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
});
|
|
|
|
|
|
if matches!(
|
|
|
|
|
|
tool_name,
|
|
|
|
|
|
"search_web" | "image_read" | "slash_run" | "event_replay" | "index_rebuild"
|
|
|
|
|
|
) {
|
|
|
|
|
|
return Ok(None);
|
|
|
|
|
|
}
|
|
|
|
|
|
if workspace_id.is_none() && page_id.is_none() && block_id.is_none() {
|
|
|
|
|
|
return Ok(None);
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(Some(RuntimeTargetWire {
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
page_id,
|
|
|
|
|
|
block_id,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn load_tool_data(
|
|
|
|
|
|
tool_name: &str,
|
|
|
|
|
|
args: &Value,
|
|
|
|
|
|
target: Option<&RuntimeTargetWire>,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
match tool_name {
|
|
|
|
|
|
"search_web" | "slash_run" | "event_replay" | "index_rebuild" => {
|
|
|
|
|
|
Ok(json!({ "source": "cli" }))
|
|
|
|
|
|
}
|
|
|
|
|
|
"image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })),
|
|
|
|
|
|
"doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => {
|
|
|
|
|
|
let page_id = read_doc_target(args, target)?;
|
|
|
|
|
|
let result = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getContent",
|
|
|
|
|
|
json!({ "id": page_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
Ok(
|
|
|
|
|
|
json!({ "source": "convex", "content": result.get("content").cloned().unwrap_or(Value::Null) }),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
"docs_read" => {
|
|
|
|
|
|
let document_id = args
|
|
|
|
|
|
.get("documentId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?;
|
|
|
|
|
|
let meta = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getMeta",
|
|
|
|
|
|
json!({ "id": document_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let content = execute_convex_query_raw(
|
|
|
|
|
|
"documents:getContent",
|
|
|
|
|
|
json!({ "id": document_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
Ok(json!({
|
|
|
|
|
|
"source": "convex",
|
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
|
"title": meta.get("title").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"workspaceId": meta.get("workspace_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"parentId": meta.get("parent_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"updatedAt": meta.get("updated_at").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"rawText": extract_raw_text(&content),
|
|
|
|
|
|
"rawTextLength": extract_raw_text(&content).chars().count(),
|
|
|
|
|
|
"content": content.get("content").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
"docs_search" => {
|
|
|
|
|
|
let workspace_id = args
|
|
|
|
|
|
.get("workspaceId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?;
|
|
|
|
|
|
let documents = execute_convex_query_raw(
|
|
|
|
|
|
"documents:listSearchDataByWorkspace",
|
|
|
|
|
|
json!({ "workspaceId": workspace_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let mindmaps = execute_convex_query_raw(
|
|
|
|
|
|
"mindmaps:listByWorkspace",
|
|
|
|
|
|
json!({ "workspaceId": workspace_id, "includeDeleted": false }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
let tables = execute_convex_query_raw(
|
|
|
|
|
|
"tables:listByWorkspaceForSearch",
|
|
|
|
|
|
json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeArchived": false, "limit": 3000 }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
).unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
let table_rows = execute_convex_query_raw(
|
|
|
|
|
|
"tables:listRowsByWorkspaceForSearch",
|
|
|
|
|
|
json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "limit": 8000 }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
let assets = execute_convex_query_raw(
|
|
|
|
|
|
"mediaAssets:listSearchDataByWorkspace",
|
|
|
|
|
|
json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeDeleted": false, "limit": 5000 }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
).unwrap_or_else(|_| Value::Array(vec![]));
|
|
|
|
|
|
Ok(json!({
|
|
|
|
|
|
"source": "convex",
|
|
|
|
|
|
"datasets": [{
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)),
|
|
|
|
|
|
"title": item.get("title").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"rawText": item.get("raw_text").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"createdAt": item.get("created_at").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"data": item.get("data").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"title": item.get("title").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"tableId": item.get("table_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
})).collect::<Vec<Value>>(),
|
|
|
|
|
|
"assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
|
|
|
|
|
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"assetType": item.get("asset_type").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"fileName": item.get("file_name").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
})).collect::<Vec<Value>>()
|
|
|
|
|
|
}]
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
|
|
|
|
|
|
let document_id = read_doc_target(args, target)?;
|
|
|
|
|
|
let mindmap_id = args
|
|
|
|
|
|
.get("mindmapId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.or_else(|| target.and_then(|value| value.block_id.as_deref()))
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?;
|
|
|
|
|
|
let result = execute_convex_query_raw(
|
|
|
|
|
|
"mindmaps:get",
|
|
|
|
|
|
json!({ "docId": document_id, "mindmapId": mindmap_id }),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
Ok(
|
|
|
|
|
|
json!({ "source": "convex", "data": result.get("data").cloned().unwrap_or(Value::Null), "meta": result.get("meta").cloned().unwrap_or(Value::Null) }),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
other => Err(CliError::validation(format!(
|
|
|
|
|
|
"暂不支持 tool 数据加载: {other}"
|
|
|
|
|
|
))),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_convex_query(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> {
|
|
|
|
|
|
execute_convex_query_raw(
|
|
|
|
|
|
&transport.function_name,
|
|
|
|
|
|
transport.args_json.clone(),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_convex_mutation(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> {
|
|
|
|
|
|
execute_convex_mutation_raw(
|
|
|
|
|
|
&transport.function_name,
|
|
|
|
|
|
transport.args_json.clone(),
|
|
|
|
|
|
cli_ctx,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_convex_query_raw(
|
|
|
|
|
|
function_name: &str,
|
|
|
|
|
|
args_json: Value,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
2026-05-06 21:44:20 +08:00
|
|
|
|
let env = ConvexCliEnv::load(cli_ctx)?;
|
2026-04-16 15:24:37 +08:00
|
|
|
|
let payload = json!({
|
|
|
|
|
|
"path": function_name,
|
|
|
|
|
|
"format": "convex_encoded_json",
|
|
|
|
|
|
"args": args_json,
|
|
|
|
|
|
});
|
|
|
|
|
|
let response = env
|
|
|
|
|
|
.client
|
|
|
|
|
|
.post(format!("{}/api/query", env.url))
|
|
|
|
|
|
.header("Authorization", env.authorization)
|
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
|
.header("Convex-Client", "mnote-cli")
|
|
|
|
|
|
.json(&payload)
|
|
|
|
|
|
.send()
|
|
|
|
|
|
.map_err(|error| CliError::transport(format!("Convex query 请求失败: {error}")))?;
|
|
|
|
|
|
parse_convex_response(response, cli_ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execute_convex_mutation_raw(
|
|
|
|
|
|
function_name: &str,
|
|
|
|
|
|
args_json: Value,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
2026-05-06 21:44:20 +08:00
|
|
|
|
let env = ConvexCliEnv::load(cli_ctx)?;
|
2026-04-16 15:24:37 +08:00
|
|
|
|
let payload = json!({
|
|
|
|
|
|
"path": function_name,
|
|
|
|
|
|
"format": "convex_encoded_json",
|
|
|
|
|
|
"args": [args_json],
|
|
|
|
|
|
});
|
|
|
|
|
|
let response = env
|
|
|
|
|
|
.client
|
|
|
|
|
|
.post(format!("{}/api/mutation", env.url))
|
|
|
|
|
|
.header("Authorization", env.authorization)
|
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
|
.header("Convex-Client", "mnote-cli")
|
|
|
|
|
|
.json(&payload)
|
|
|
|
|
|
.send()
|
|
|
|
|
|
.map_err(|error| CliError::transport(format!("Convex mutation 请求失败: {error}")))?;
|
|
|
|
|
|
parse_convex_response(response, cli_ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn parse_convex_response(
|
|
|
|
|
|
response: reqwest::blocking::Response,
|
|
|
|
|
|
_cli_ctx: &CliContext,
|
|
|
|
|
|
) -> CliResult<Value> {
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let body: Value = response
|
|
|
|
|
|
.json()
|
|
|
|
|
|
.map_err(|error| CliError::transport(format!("Convex 响应解析失败: {error}")))?;
|
|
|
|
|
|
if !status.is_success() {
|
|
|
|
|
|
let message = body
|
|
|
|
|
|
.get("errorMessage")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
|
|
|
|
|
return Err(CliError::transport(message));
|
|
|
|
|
|
}
|
|
|
|
|
|
match body.get("status").and_then(Value::as_str) {
|
|
|
|
|
|
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
|
|
|
|
|
|
Some("error") => Err(CliError::transport(
|
|
|
|
|
|
body.get("errorMessage")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.unwrap_or("Convex 返回 error")
|
|
|
|
|
|
.to_string(),
|
|
|
|
|
|
)),
|
|
|
|
|
|
_ => Err(CliError::transport(format!("未知 Convex 响应: {body}"))),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
struct ConvexCliEnv {
|
|
|
|
|
|
url: String,
|
|
|
|
|
|
authorization: String,
|
|
|
|
|
|
client: Client,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ConvexCliEnv {
|
2026-05-06 21:44:20 +08:00
|
|
|
|
fn load(cli_ctx: &CliContext) -> CliResult<Self> {
|
2026-04-16 15:24:37 +08:00
|
|
|
|
let url = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL")?
|
|
|
|
|
|
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
|
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
|
CliError::validation("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let admin_key = read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY")?
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("缺少 CONVEX_SELF_HOSTED_ADMIN_KEY"))?;
|
2026-05-06 21:44:20 +08:00
|
|
|
|
let dev_user_id = normalize_actor_for_convex_identity(&cli_ctx.actor_id)
|
|
|
|
|
|
.or(read_env_or_dotenv("DEV_USER_ID")?)
|
|
|
|
|
|
.unwrap_or_else(|| "dev-user".into());
|
2026-04-16 15:24:37 +08:00
|
|
|
|
let dev_user_name =
|
|
|
|
|
|
read_env_or_dotenv("DEV_USER_NAME")?.unwrap_or_else(|| "开发用户".into());
|
|
|
|
|
|
let dev_user_email =
|
|
|
|
|
|
read_env_or_dotenv("DEV_USER_EMAIL")?.unwrap_or_else(|| "dev@mnote.local".into());
|
2026-05-06 21:44:20 +08:00
|
|
|
|
let identity = build_convex_dev_identity(&dev_user_id, &dev_user_name, &dev_user_email);
|
2026-04-16 15:24:37 +08:00
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD
|
|
|
|
|
|
.encode(serde_json::to_string(&identity).map_err(|error| {
|
|
|
|
|
|
CliError::transport(format!("开发用户身份序列化失败: {error}"))
|
|
|
|
|
|
})?);
|
|
|
|
|
|
let authorization = format!("Convex {admin_key}:{encoded}");
|
|
|
|
|
|
let client = Client::builder()
|
|
|
|
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
|
|
|
|
.build()
|
|
|
|
|
|
.map_err(|error| CliError::transport(format!("HTTP 客户端创建失败: {error}")))?;
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
|
url: url.trim().trim_end_matches('/').to_string(),
|
|
|
|
|
|
authorization,
|
|
|
|
|
|
client,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
|
fn normalize_actor_for_convex_identity(actor_id: &str) -> Option<String> {
|
|
|
|
|
|
let trimmed = actor_id.trim();
|
|
|
|
|
|
if trimmed.is_empty() || trimmed == "anonymous" || trimmed == "cli_user" {
|
|
|
|
|
|
None
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Some(trimmed.to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_convex_dev_identity(user_id: &str, name: &str, email: &str) -> Value {
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"subject": user_id,
|
|
|
|
|
|
"issuer": "https://mnote.local/dev-auth",
|
|
|
|
|
|
"tokenIdentifier": format!("dev-user|{}", user_id),
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"email": email,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:24:37 +08:00
|
|
|
|
fn read_env_or_dotenv(key: &str) -> CliResult<Option<String>> {
|
|
|
|
|
|
if let Ok(value) = env::var(key) {
|
|
|
|
|
|
let trimmed = value.trim().to_string();
|
|
|
|
|
|
if !trimmed.is_empty() {
|
|
|
|
|
|
return Ok(Some(trimmed));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
|
.join("../../..")
|
|
|
|
|
|
.join(".env.all");
|
|
|
|
|
|
let content = std::fs::read_to_string(&root)
|
|
|
|
|
|
.map_err(|error| CliError::transport(format!("读取 .env.all 失败: {error}")))?;
|
|
|
|
|
|
for line in content.lines() {
|
|
|
|
|
|
let line = line.trim_end_matches('\r');
|
|
|
|
|
|
if line.starts_with('#') || line.trim().is_empty() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
|
|
|
|
if k.trim() == key {
|
|
|
|
|
|
let trimmed = v.trim().trim_matches('"').to_string();
|
|
|
|
|
|
if !trimmed.is_empty() {
|
|
|
|
|
|
return Ok(Some(trimmed));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_runtime_context(
|
|
|
|
|
|
context: &CliOutputContext,
|
|
|
|
|
|
cli_ctx: &CliContext,
|
|
|
|
|
|
) -> RuntimeBridgeContextWire {
|
|
|
|
|
|
RuntimeBridgeContextWire {
|
|
|
|
|
|
deployment_id: None,
|
|
|
|
|
|
project_id: None,
|
|
|
|
|
|
workspace_id: context.workspace_id.clone(),
|
|
|
|
|
|
request_id: context.request_id.clone(),
|
|
|
|
|
|
trace_id: context.trace_id.clone(),
|
|
|
|
|
|
actor: RuntimeActorWire {
|
|
|
|
|
|
actor_type: context.actor_type.clone(),
|
|
|
|
|
|
actor_id: context.actor_id.clone(),
|
|
|
|
|
|
session_id: context.session_id.clone(),
|
|
|
|
|
|
},
|
|
|
|
|
|
source: RuntimeSourceWire {
|
|
|
|
|
|
channel: "cli".into(),
|
|
|
|
|
|
client: "mnote-cli".into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
tenant_id: None,
|
|
|
|
|
|
auth_token: None,
|
|
|
|
|
|
idempotency_key: cli_ctx.idempotency_key.clone(),
|
|
|
|
|
|
validate_only: cli_ctx.validate_only,
|
|
|
|
|
|
dry_run: cli_ctx.dry_run,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn extract_blocks(content_result: &Value) -> Vec<Value> {
|
|
|
|
|
|
if let Some(content) = content_result.get("content") {
|
|
|
|
|
|
if let Some(array) = content.as_array() {
|
|
|
|
|
|
return array.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(array) = content.get("blocks").and_then(Value::as_array) {
|
|
|
|
|
|
return array.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
vec![]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn write_blocks_back(content_result: &Value, blocks: Value) -> Value {
|
|
|
|
|
|
let current = content_result
|
|
|
|
|
|
.get("content")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or(Value::Null);
|
|
|
|
|
|
if current.is_array() {
|
|
|
|
|
|
return blocks;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(obj) = current.as_object() {
|
|
|
|
|
|
let mut next = obj.clone();
|
|
|
|
|
|
next.insert("blocks".into(), blocks);
|
|
|
|
|
|
return Value::Object(next);
|
|
|
|
|
|
}
|
|
|
|
|
|
json!({ "blocks": blocks })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn find_block_index(blocks: &[Value], target_id: &str) -> Option<usize> {
|
|
|
|
|
|
blocks.iter().position(|block| {
|
|
|
|
|
|
block
|
|
|
|
|
|
.get("id")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(|value| value == target_id)
|
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn remove_block_subtree(mut blocks: Vec<Value>, block_id: &str) -> CliResult<(Value, Vec<Value>)> {
|
|
|
|
|
|
if let Some(index) = blocks
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.position(|block| block.get("id").and_then(Value::as_str) == Some(block_id))
|
|
|
|
|
|
{
|
|
|
|
|
|
let removed = blocks.remove(index);
|
|
|
|
|
|
return Ok((removed, blocks));
|
|
|
|
|
|
}
|
|
|
|
|
|
for block in &mut blocks {
|
|
|
|
|
|
if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) {
|
|
|
|
|
|
let (removed, next_children) = remove_block_subtree(children.clone(), block_id)?;
|
|
|
|
|
|
*children = next_children;
|
|
|
|
|
|
return Ok((removed, blocks));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(CliError::validation(format!("未找到 blockId:{block_id}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn replace_block_subtree(
|
|
|
|
|
|
mut blocks: Vec<Value>,
|
|
|
|
|
|
block_id: &str,
|
|
|
|
|
|
next_block: &Value,
|
|
|
|
|
|
) -> CliResult<Vec<Value>> {
|
|
|
|
|
|
let mut normalized = next_block
|
|
|
|
|
|
.as_object()
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("block patch snapshot 必须是对象"))?;
|
|
|
|
|
|
normalized.insert("id".into(), Value::String(block_id.to_string()));
|
|
|
|
|
|
let next_value = Value::Object(normalized);
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(index) = blocks
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.position(|block| block.get("id").and_then(Value::as_str) == Some(block_id))
|
|
|
|
|
|
{
|
|
|
|
|
|
blocks[index] = next_value;
|
|
|
|
|
|
return Ok(blocks);
|
|
|
|
|
|
}
|
|
|
|
|
|
for block in &mut blocks {
|
|
|
|
|
|
if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) {
|
|
|
|
|
|
let next_children = replace_block_subtree(children.clone(), block_id, &next_value)?;
|
|
|
|
|
|
*children = next_children;
|
|
|
|
|
|
return Ok(blocks);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(CliError::validation(format!("未找到 blockId:{block_id}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn extract_raw_text(content_result: &Value) -> String {
|
|
|
|
|
|
fn walk(value: &Value, parts: &mut Vec<String>) {
|
|
|
|
|
|
match value {
|
|
|
|
|
|
Value::Array(items) => {
|
|
|
|
|
|
for item in items {
|
|
|
|
|
|
walk(item, parts);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Value::Object(map) => {
|
|
|
|
|
|
if let Some(text) = map.get("text").and_then(Value::as_str) {
|
|
|
|
|
|
if !text.trim().is_empty() {
|
|
|
|
|
|
parts.push(text.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(content) = map.get("content") {
|
|
|
|
|
|
walk(content, parts);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(children) = map.get("children") {
|
|
|
|
|
|
walk(children, parts);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(blocks) = map.get("blocks") {
|
|
|
|
|
|
walk(blocks, parts);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut parts = Vec::new();
|
|
|
|
|
|
if let Some(content) = content_result.get("content") {
|
|
|
|
|
|
walk(content, &mut parts);
|
|
|
|
|
|
}
|
|
|
|
|
|
parts.join("\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResult<String> {
|
|
|
|
|
|
args.get("documentId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.or_else(|| args.get("pageId").and_then(Value::as_str))
|
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
|
.or_else(|| target.and_then(|value| value.page_id.clone()))
|
|
|
|
|
|
.ok_or_else(|| CliError::validation("缺少 documentId/pageId"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn required_str<'a>(value: &'a Value, key: &str) -> CliResult<&'a str> {
|
|
|
|
|
|
value
|
|
|
|
|
|
.get(key)
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.ok_or_else(|| CliError::validation(format!("缺少 {key}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
fn build_query_output(
|
|
|
|
|
|
domain: &str,
|
|
|
|
|
|
action: &str,
|
|
|
|
|
|
bridge: &BridgeContext,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx: &CliContext,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
name: String,
|
|
|
|
|
|
normalized_input: Value,
|
|
|
|
|
|
transport: CliTransportPlan,
|
|
|
|
|
|
) -> CliJsonOutput {
|
|
|
|
|
|
CliJsonOutput {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
entrypoint: "mnote-cli",
|
|
|
|
|
|
domain: domain.into(),
|
|
|
|
|
|
action: action.into(),
|
2026-05-06 21:44:20 +08:00
|
|
|
|
context: build_output_context(bridge, ctx),
|
2026-04-15 20:01:12 +08:00
|
|
|
|
operation: CliOperationOutput::Query {
|
|
|
|
|
|
name,
|
|
|
|
|
|
normalized_input,
|
|
|
|
|
|
transport,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution: None,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_command_output(
|
|
|
|
|
|
domain: &str,
|
|
|
|
|
|
action: &str,
|
|
|
|
|
|
bridge: &BridgeContext,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
ctx: &CliContext,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
name: String,
|
|
|
|
|
|
command_id: String,
|
|
|
|
|
|
normalized_input: Value,
|
|
|
|
|
|
transport: CliTransportPlan,
|
|
|
|
|
|
) -> CliJsonOutput {
|
|
|
|
|
|
CliJsonOutput {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
entrypoint: "mnote-cli",
|
|
|
|
|
|
domain: domain.into(),
|
|
|
|
|
|
action: action.into(),
|
2026-05-06 21:44:20 +08:00
|
|
|
|
context: build_output_context(bridge, ctx),
|
2026-04-15 20:01:12 +08:00
|
|
|
|
operation: CliOperationOutput::Command {
|
|
|
|
|
|
name,
|
|
|
|
|
|
command_id,
|
|
|
|
|
|
normalized_input,
|
|
|
|
|
|
transport,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
execution: None,
|
2026-04-15 20:01:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_output_context(bridge: &BridgeContext, ctx: &CliContext) -> CliOutputContext {
|
|
|
|
|
|
CliOutputContext {
|
|
|
|
|
|
request_id: bridge.request_id.clone(),
|
|
|
|
|
|
trace_id: bridge.trace_id.clone(),
|
|
|
|
|
|
actor_id: bridge.actor_id.clone(),
|
|
|
|
|
|
actor_type: bridge.actor_type.clone(),
|
|
|
|
|
|
session_id: bridge.session_id.clone(),
|
2026-05-06 21:44:20 +08:00
|
|
|
|
reason: ctx.reason.clone(),
|
2026-04-15 20:01:12 +08:00
|
|
|
|
workspace_id: bridge.workspace_id.clone(),
|
|
|
|
|
|
idempotency_key: ctx.idempotency_key.clone(),
|
|
|
|
|
|
validate_only: bridge.validate_only,
|
|
|
|
|
|
dry_run: bridge.dry_run,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_bridge_context(
|
|
|
|
|
|
ctx: &CliContext,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
domain: &str,
|
|
|
|
|
|
action: &str,
|
|
|
|
|
|
target: &str,
|
|
|
|
|
|
) -> BridgeContext {
|
|
|
|
|
|
let key = sanitize_id(&format!("{domain}_{action}_{target}"));
|
|
|
|
|
|
BridgeContext {
|
|
|
|
|
|
deployment_id: None,
|
|
|
|
|
|
project_id: None,
|
|
|
|
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
|
|
|
|
|
request_id: format!("req_cli_{key}"),
|
|
|
|
|
|
trace_id: format!("trace_cli_{key}"),
|
|
|
|
|
|
actor_type: ctx.actor_type.clone(),
|
|
|
|
|
|
actor_id: ctx.actor_id.clone(),
|
|
|
|
|
|
session_id: ctx.session_id.clone(),
|
|
|
|
|
|
tenant_id: None,
|
|
|
|
|
|
auth_token: None,
|
|
|
|
|
|
source_channel: "cli".into(),
|
|
|
|
|
|
source_client: "mnote-cli".into(),
|
|
|
|
|
|
idempotency_key: ctx.idempotency_key.clone(),
|
|
|
|
|
|
validate_only: ctx.validate_only,
|
|
|
|
|
|
dry_run: ctx.dry_run,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_actor_payload(ctx: &CliContext) -> ActorPayload {
|
|
|
|
|
|
ActorPayload {
|
|
|
|
|
|
actor_type: ctx.actor_type.clone(),
|
|
|
|
|
|
actor_id: ctx.actor_id.clone(),
|
|
|
|
|
|
session_id: ctx.session_id.clone(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_source_payload() -> SourcePayload {
|
|
|
|
|
|
SourcePayload {
|
|
|
|
|
|
channel: "cli".into(),
|
|
|
|
|
|
client: "mnote-cli".into(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_command_id(domain: &str, action: &str, target: &str) -> String {
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"cmd_{}",
|
|
|
|
|
|
sanitize_id(&format!("{domain}_{action}_{target}"))
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn sanitize_id(input: &str) -> String {
|
|
|
|
|
|
input
|
|
|
|
|
|
.chars()
|
|
|
|
|
|
.map(|ch| {
|
|
|
|
|
|
if ch.is_ascii_alphanumeric() {
|
|
|
|
|
|
ch.to_ascii_lowercase()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
'_'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn parse_json_value(raw: &str, field: &str) -> CliResult<Value> {
|
|
|
|
|
|
serde_json::from_str::<Value>(raw)
|
|
|
|
|
|
.map_err(|error| CliError::validation(format!("{field} 不是合法 JSON: {error}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn invocation_kind_label(kind: &InvocationKind) -> &'static str {
|
|
|
|
|
|
match kind {
|
|
|
|
|
|
InvocationKind::Command => "command",
|
|
|
|
|
|
InvocationKind::Query => "query",
|
|
|
|
|
|
InvocationKind::Job => "job",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
|
|
|
|
|
|
CliError::validation(error.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 21:03:25 +08:00
|
|
|
|
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
|
|
|
|
|
|
CliError::validation(error.to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn supported_surface_covers_phase2_minimum_domains() {
|
|
|
|
|
|
let commands = supported_command_surface();
|
|
|
|
|
|
assert!(commands.contains(&"page get"));
|
2026-04-16 15:24:37 +08:00
|
|
|
|
assert!(commands.contains(&"page create"));
|
|
|
|
|
|
assert!(commands.contains(&"page move"));
|
2026-04-15 20:01:12 +08:00
|
|
|
|
assert!(commands.contains(&"block patch"));
|
2026-04-16 15:24:37 +08:00
|
|
|
|
assert!(commands.contains(&"block move"));
|
2026-04-15 20:01:12 +08:00
|
|
|
|
assert!(commands.contains(&"mindmap get"));
|
|
|
|
|
|
assert!(commands.contains(&"search documents"));
|
|
|
|
|
|
assert!(commands.contains(&"sidebar dataset"));
|
2026-04-19 21:03:25 +08:00
|
|
|
|
assert!(commands.contains(&"editor markdown-roundtrip"));
|
|
|
|
|
|
assert!(commands.contains(&"editor session-demo"));
|
2026-04-15 20:01:12 +08:00
|
|
|
|
assert!(commands.contains(&"tool run"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 21:03:25 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn editor_markdown_roundtrip_returns_outline_and_markdown() {
|
|
|
|
|
|
let output =
|
|
|
|
|
|
editor_markdown_roundtrip("# 标题\n普通段落").expect("roundtrip should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(output.operation, "markdown_roundtrip");
|
|
|
|
|
|
assert_eq!(output.block_count, 2);
|
|
|
|
|
|
assert!(output.output_markdown.contains("# 标题"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
output.visible_block_ids,
|
|
|
|
|
|
vec!["imported_1".to_string(), "imported_2".to_string()]
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(output.outline.len(), 1);
|
|
|
|
|
|
assert_eq!(output.outline[0].title, "标题");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn editor_session_demo_applies_insert_and_redo() {
|
|
|
|
|
|
let output = editor_session_demo("原始段落").expect("session demo should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(output.operation, "session_demo");
|
|
|
|
|
|
assert_eq!(output.block_count, 2);
|
|
|
|
|
|
assert_eq!(output.undo_applied, Some(true));
|
|
|
|
|
|
assert_eq!(output.redo_applied, Some(true));
|
|
|
|
|
|
assert!(output.output_markdown.contains("原始段落 [edited]"));
|
|
|
|
|
|
assert!(output.output_markdown.contains("CLI demo block"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn editor_ai_pipeline_exposes_audit_and_change_report() {
|
|
|
|
|
|
let output = editor_ai_pipeline(
|
|
|
|
|
|
EditorInputKind::PlainText,
|
|
|
|
|
|
EditorAiScenario::MeetingNotesToTodos,
|
|
|
|
|
|
"待办:整理会议纪要\n普通段落",
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("ai pipeline should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(output.operation, "ai_pipeline");
|
|
|
|
|
|
assert_eq!(output.audit.len(), 1);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
output.change_report.updated_blocks,
|
|
|
|
|
|
vec!["imported_1".to_string()]
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(output.output_markdown.contains("待办:整理会议纪要"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn editor_ai_pipeline_reorders_pages() {
|
|
|
|
|
|
let output = editor_ai_pipeline(
|
|
|
|
|
|
EditorInputKind::PlainText,
|
|
|
|
|
|
EditorAiScenario::PageReorder,
|
|
|
|
|
|
"第一页\n\n第二页",
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("ai pipeline should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
output.change_report.moved_blocks,
|
|
|
|
|
|
vec!["imported_1".to_string()]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ai_regression_meeting_notes_to_todos() {
|
|
|
|
|
|
let output = editor_ai_pipeline(
|
|
|
|
|
|
EditorInputKind::PlainText,
|
|
|
|
|
|
EditorAiScenario::MeetingNotesToTodos,
|
|
|
|
|
|
"待办:整理会议纪要\n普通段落",
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("ai regression meeting notes should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert!(output.output_markdown.contains("- [ ] 待办:整理会议纪要"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
output.change_report.updated_blocks,
|
|
|
|
|
|
vec!["imported_1".to_string()]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ai_regression_long_paragraph_to_title() {
|
|
|
|
|
|
let output = editor_ai_pipeline(
|
|
|
|
|
|
EditorInputKind::PlainText,
|
|
|
|
|
|
EditorAiScenario::LongParagraphToTitle,
|
|
|
|
|
|
"这是一个很长的段落,用来验证 AI 回归样例会把首块提炼成标题,并保留后续结构化内容。",
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("ai regression long paragraph should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert!(output.output_markdown.starts_with("# 这是一个很长的段落"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
output.change_report.updated_blocks,
|
|
|
|
|
|
vec!["imported_1".to_string()]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ai_regression_page_reorder() {
|
|
|
|
|
|
let output = editor_ai_pipeline(
|
|
|
|
|
|
EditorInputKind::PlainText,
|
|
|
|
|
|
EditorAiScenario::PageReorder,
|
|
|
|
|
|
"第一页\n\n第二页",
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("ai regression reorder should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
output.change_report.moved_blocks,
|
|
|
|
|
|
vec!["imported_1".to_string()]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn page_save_json_contract_uses_documents_save() {
|
|
|
|
|
|
let output = plan_page_save(
|
|
|
|
|
|
&CliContext::default(),
|
|
|
|
|
|
"page_1",
|
|
|
|
|
|
Some("ws_1"),
|
|
|
|
|
|
Some(7),
|
|
|
|
|
|
r#"[{"id":"block_1"}]"#,
|
|
|
|
|
|
Some("conflict_1"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("page save plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
match output.operation {
|
|
|
|
|
|
CliOperationOutput::Command {
|
|
|
|
|
|
name,
|
|
|
|
|
|
command_id,
|
|
|
|
|
|
transport,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!(name, "documents.save");
|
|
|
|
|
|
assert_eq!(command_id, "cmd_page_save_page_1");
|
|
|
|
|
|
assert_eq!(transport.function_name, "documents:updateContent");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
transport.args_json,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": "page_1",
|
|
|
|
|
|
"content": [{"id": "block_1"}],
|
|
|
|
|
|
"expectedRevision": 7,
|
|
|
|
|
|
"conflictDetectionKey": "conflict_1",
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => panic!("expected command output"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn output_context_keeps_reason_for_audit_trace() {
|
|
|
|
|
|
let ctx = CliContext {
|
|
|
|
|
|
reason: Some("phase7-cli-first".into()),
|
|
|
|
|
|
..CliContext::default()
|
|
|
|
|
|
};
|
|
|
|
|
|
let output = plan_page_save(&ctx, "page_1", Some("ws_1"), None, "[]", None)
|
|
|
|
|
|
.expect("page save plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(output.context.reason.as_deref(), Some("phase7-cli-first"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:24:37 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn page_create_json_contract_uses_documents_create() {
|
|
|
|
|
|
let output = plan_page_create(
|
|
|
|
|
|
&CliContext::default(),
|
|
|
|
|
|
&PageCreateArgs {
|
|
|
|
|
|
page_id: "page_1",
|
|
|
|
|
|
workspace_id: "ws_1",
|
|
|
|
|
|
parent_id: Some("parent_1"),
|
|
|
|
|
|
title: "新页面",
|
|
|
|
|
|
access_scope: "private",
|
|
|
|
|
|
content_json: r#"[{"id":"block_1"}]"#,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("page create plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
match output.operation {
|
|
|
|
|
|
CliOperationOutput::Command {
|
|
|
|
|
|
name, transport, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!(name, "documents.create");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
transport.function_name,
|
|
|
|
|
|
"documents:createWithParentReference"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(transport.args_json["workspaceId"], json!("ws_1"));
|
|
|
|
|
|
assert_eq!(transport.args_json["parentId"], json!("parent_1"));
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => panic!("expected command output"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn convex_dev_identity_prefers_explicit_cli_actor() {
|
|
|
|
|
|
let ctx = CliContext {
|
|
|
|
|
|
actor_id: "nn7bhmt782sykdrecah0rbe2nx867ks1".into(),
|
|
|
|
|
|
..CliContext::default()
|
|
|
|
|
|
};
|
|
|
|
|
|
let user_id = normalize_actor_for_convex_identity(&ctx.actor_id)
|
|
|
|
|
|
.expect("显式 CLI actor 应成为 Convex 写入身份");
|
|
|
|
|
|
let identity = build_convex_dev_identity(&user_id, "开发用户", "dev@mnote.local");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
identity["subject"],
|
|
|
|
|
|
json!("nn7bhmt782sykdrecah0rbe2nx867ks1")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
identity["tokenIdentifier"],
|
|
|
|
|
|
json!("dev-user|nn7bhmt782sykdrecah0rbe2nx867ks1")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn convex_dev_identity_ignores_legacy_default_cli_actor() {
|
|
|
|
|
|
assert_eq!(normalize_actor_for_convex_identity("cli_user"), None);
|
|
|
|
|
|
assert_eq!(normalize_actor_for_convex_identity(" anonymous "), None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:24:37 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn page_move_json_contract_uses_documents_move() {
|
|
|
|
|
|
let output = plan_page_move(
|
|
|
|
|
|
&CliContext::default(),
|
|
|
|
|
|
&PageMoveArgs {
|
|
|
|
|
|
page_id: "page_1",
|
|
|
|
|
|
workspace_id: Some("ws_1"),
|
|
|
|
|
|
parent_id: Some("parent_2"),
|
|
|
|
|
|
sort_order: 3,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("page move plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
match output.operation {
|
|
|
|
|
|
CliOperationOutput::Command {
|
|
|
|
|
|
name, transport, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!(name, "documents.move");
|
|
|
|
|
|
assert_eq!(transport.function_name, "documents:move");
|
|
|
|
|
|
assert_eq!(transport.args_json["sortOrder"], json!(3));
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => panic!("expected command output"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn sidebar_dataset_json_contract_uses_single_dataset_query() {
|
|
|
|
|
|
let output = plan_sidebar_dataset(&CliContext::default(), "ws_1")
|
|
|
|
|
|
.expect("sidebar dataset plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
match output.operation {
|
|
|
|
|
|
CliOperationOutput::Query {
|
|
|
|
|
|
name, transport, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!(name, "sidebar.dataset.list");
|
|
|
|
|
|
assert_eq!(transport.function_name, "sidebar:datasetList");
|
|
|
|
|
|
assert_eq!(transport.args_json, json!({ "workspaceId": "ws_1" }));
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => panic!("expected query output"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn tool_run_keeps_kind_and_args_json() {
|
|
|
|
|
|
let output = plan_tool_run(
|
|
|
|
|
|
&CliContext::default(),
|
|
|
|
|
|
"doc_get",
|
|
|
|
|
|
InvocationKind::Query,
|
|
|
|
|
|
ToolExecutionMode::Plan,
|
|
|
|
|
|
r#"{"pageId":"page_1"}"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("tool plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
match output.operation {
|
|
|
|
|
|
CliOperationOutput::Tool {
|
|
|
|
|
|
tool_name,
|
|
|
|
|
|
invocation_kind,
|
|
|
|
|
|
execution_mode,
|
|
|
|
|
|
toolset_id,
|
|
|
|
|
|
effect,
|
|
|
|
|
|
requires_confirmation,
|
|
|
|
|
|
normalized_input,
|
2026-04-16 15:24:37 +08:00
|
|
|
|
..
|
2026-04-15 20:01:12 +08:00
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!(tool_name, "doc_get");
|
|
|
|
|
|
assert_eq!(invocation_kind, "query");
|
|
|
|
|
|
assert_eq!(execution_mode, "plan");
|
|
|
|
|
|
assert_eq!(toolset_id, "toolset.doc_read");
|
|
|
|
|
|
assert_eq!(effect, "read");
|
|
|
|
|
|
assert!(!requires_confirmation);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
normalized_input,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"toolName": "doc_get",
|
|
|
|
|
|
"invocationKind": "query",
|
|
|
|
|
|
"executionMode": "plan",
|
|
|
|
|
|
"toolsetId": "toolset.doc_read",
|
|
|
|
|
|
"effect": "read",
|
|
|
|
|
|
"requiresConfirmation": false,
|
|
|
|
|
|
"validateOnly": false,
|
|
|
|
|
|
"dryRun": false,
|
|
|
|
|
|
"args": {
|
|
|
|
|
|
"pageId": "page_1",
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => panic!("expected tool output"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn tool_run_rejects_unknown_tool() {
|
|
|
|
|
|
let error = plan_tool_run(
|
|
|
|
|
|
&CliContext::default(),
|
|
|
|
|
|
"doc_unknown",
|
|
|
|
|
|
InvocationKind::Query,
|
|
|
|
|
|
ToolExecutionMode::Plan,
|
|
|
|
|
|
r#"{}"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect_err("unknown tool should fail");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(error.code, "VALIDATION_ERROR");
|
|
|
|
|
|
assert!(error.message.contains("未知 tool"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn mindmap_get_json_contract_uses_mindmaps_get() {
|
|
|
|
|
|
let output = plan_mindmap_get(&CliContext::default(), Some("ws_1"), "page_1", "mind_1")
|
|
|
|
|
|
.expect("mindmap get plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
match output.operation {
|
|
|
|
|
|
CliOperationOutput::Query {
|
|
|
|
|
|
name, transport, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!(name, "mindmaps.get");
|
|
|
|
|
|
assert_eq!(transport.function_name, "mindmaps:get");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
transport.args_json,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"docId": "page_1",
|
|
|
|
|
|
"mindmapId": "mind_1",
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => panic!("expected query output"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn mindmap_put_json_contract_uses_mindmaps_put() {
|
|
|
|
|
|
let output = plan_mindmap_put(
|
|
|
|
|
|
&CliContext::default(),
|
|
|
|
|
|
Some("ws_1"),
|
|
|
|
|
|
"page_1",
|
|
|
|
|
|
"mind_1",
|
|
|
|
|
|
r#"{"data":{"text":"中心主题"},"children":[]}"#,
|
|
|
|
|
|
true,
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("mindmap put plan should build");
|
|
|
|
|
|
|
|
|
|
|
|
match output.operation {
|
|
|
|
|
|
CliOperationOutput::Command {
|
|
|
|
|
|
name, transport, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!(name, "mindmaps.put");
|
|
|
|
|
|
assert_eq!(transport.function_name, "mindmaps:put");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
transport.args_json,
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"docId": "page_1",
|
|
|
|
|
|
"mindmapId": "mind_1",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"text": "中心主题"
|
|
|
|
|
|
},
|
|
|
|
|
|
"children": []
|
|
|
|
|
|
},
|
|
|
|
|
|
"createOnly": true,
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => panic!("expected command output"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 15:24:37 +08:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn replace_block_subtree_preserves_other_blocks() {
|
|
|
|
|
|
let next_blocks = replace_block_subtree(
|
|
|
|
|
|
vec![
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": "block_1",
|
|
|
|
|
|
"type": "paragraph",
|
|
|
|
|
|
"content": [{"type": "text", "text": "old"}],
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
}),
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": "block_2",
|
|
|
|
|
|
"type": "paragraph",
|
|
|
|
|
|
"content": [{"type": "text", "text": "keep"}],
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
}),
|
|
|
|
|
|
],
|
|
|
|
|
|
"block_1",
|
|
|
|
|
|
&json!({
|
|
|
|
|
|
"type": "paragraph",
|
|
|
|
|
|
"content": [{"type": "text", "text": "new"}],
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("block replacement should succeed");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
next_blocks,
|
|
|
|
|
|
vec![
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": "block_1",
|
|
|
|
|
|
"type": "paragraph",
|
|
|
|
|
|
"content": [{"type": "text", "text": "new"}],
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
}),
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"id": "block_2",
|
|
|
|
|
|
"type": "paragraph",
|
|
|
|
|
|
"content": [{"type": "text", "text": "keep"}],
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
}),
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn read_env_or_dotenv_trims_crlf_values() {
|
|
|
|
|
|
let got = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL").expect("read env should work");
|
|
|
|
|
|
assert_eq!(got.as_deref(), Some("http://127.0.0.1:3210"));
|
|
|
|
|
|
}
|
2026-04-15 20:01:12 +08:00
|
|
|
|
}
|