Fix tiptap selection sync and toolbar event loop
This commit is contained in:
@@ -244,7 +244,10 @@ pub fn prepare_proxy_request(
|
||||
}
|
||||
|
||||
if let Some(origin) = supa_internal_origin.as_ref() {
|
||||
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
||||
let port = origin
|
||||
.port()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default();
|
||||
if let Some(hostname) = origin.host_str() {
|
||||
add_allowed(hostname, &port);
|
||||
if is_local_hostname(hostname) {
|
||||
@@ -260,7 +263,10 @@ pub fn prepare_proxy_request(
|
||||
}
|
||||
|
||||
if let Some(origin) = convex_origin.as_ref() {
|
||||
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
||||
let port = origin
|
||||
.port()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default();
|
||||
if let Some(hostname) = origin.host_str() {
|
||||
add_allowed(hostname, &port);
|
||||
if is_local_hostname(hostname) {
|
||||
@@ -275,7 +281,10 @@ pub fn prepare_proxy_request(
|
||||
.host_str()
|
||||
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
|
||||
|
||||
if is_private_ipv4(hostname) && !is_local_hostname(hostname) && !allowed_hostnames.contains(hostname) {
|
||||
if is_private_ipv4(hostname)
|
||||
&& !is_local_hostname(hostname)
|
||||
&& !allowed_hostnames.contains(hostname)
|
||||
{
|
||||
return Err("禁止访问内网/私有地址".into());
|
||||
}
|
||||
|
||||
@@ -303,8 +312,14 @@ pub fn prepare_proxy_request(
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(anon_key) = input.supabase_anon_key.filter(|value| !value.trim().is_empty()) {
|
||||
let matches_public = supa.as_ref().map(|(value, _)| value == hostname).unwrap_or(false);
|
||||
if let Some(anon_key) = input
|
||||
.supabase_anon_key
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let matches_public = supa
|
||||
.as_ref()
|
||||
.map(|(value, _)| value == hostname)
|
||||
.unwrap_or(false);
|
||||
let matches_internal = supa_internal_origin
|
||||
.as_ref()
|
||||
.and_then(|value| value.host_str())
|
||||
@@ -332,7 +347,10 @@ pub fn prepare_callback(
|
||||
workspace_id: input.workspace_id.clone(),
|
||||
document_id: input.document_id.clone(),
|
||||
user_id: input.user_id.clone(),
|
||||
session_id: input.session_id.clone().or_else(|| Some("onlyoffice-callback".into())),
|
||||
session_id: input
|
||||
.session_id
|
||||
.clone()
|
||||
.or_else(|| Some("onlyoffice-callback".into())),
|
||||
});
|
||||
|
||||
if input.status != 2 && input.status != 6 {
|
||||
@@ -464,7 +482,8 @@ fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
|
||||
let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
|
||||
let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
||||
let signing_input = format!("{header_part}.{payload_part}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
|
||||
mac.update(signing_input.as_bytes());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
let signature_part = URL_SAFE_NO_PAD.encode(signature);
|
||||
@@ -512,7 +531,9 @@ fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
|
||||
let url = Url::parse(value).ok()?;
|
||||
return Some((
|
||||
url.host_str()?.to_string(),
|
||||
url.port().map(|value| value.to_string()).unwrap_or_default(),
|
||||
url.port()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
Some((value.to_string(), String::new()))
|
||||
@@ -574,7 +595,9 @@ mod tests {
|
||||
supabase_anon_key: Some("anon".into()),
|
||||
})
|
||||
.expect("proxy should prepare");
|
||||
assert!(result.target_url.starts_with("http://127.0.0.1:18000/storage/v1/"));
|
||||
assert!(result
|
||||
.target_url
|
||||
.starts_with("http://127.0.0.1:18000/storage/v1/"));
|
||||
assert_eq!(result.forward_headers.len(), 2);
|
||||
}
|
||||
|
||||
@@ -587,7 +610,9 @@ mod tests {
|
||||
user_id: Some("user_1".into()),
|
||||
session_id: None,
|
||||
status: 6,
|
||||
url: Some("http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into()),
|
||||
url: Some(
|
||||
"http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into(),
|
||||
),
|
||||
key: Some("doc_key_1".into()),
|
||||
onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
use crate::editor::model::{BlockProps, ContentNode, EditorBlock, EditorBlockType, ReferenceToken};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorCommandKind {
|
||||
ReplaceBlock,
|
||||
InsertBlockAfter,
|
||||
DeleteBlock,
|
||||
SplitBlock,
|
||||
MergeWithPrevious,
|
||||
MoveBlock,
|
||||
IndentBlock,
|
||||
OutdentBlock,
|
||||
ToggleHeadingCollapse,
|
||||
AttachReferenceToken,
|
||||
DetachReferenceToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorReplaceBlock {
|
||||
pub block_id: String,
|
||||
pub block_type: Option<EditorBlockType>,
|
||||
pub props: Option<BlockProps>,
|
||||
pub content_nodes: Option<Vec<ContentNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorInsertBlockAfter {
|
||||
pub after_block_id: String,
|
||||
pub block: EditorBlock,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorDeleteBlock {
|
||||
pub block_id: String,
|
||||
pub preserve_children: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorSplitBlock {
|
||||
pub block_id: String,
|
||||
pub content_node_index: u32,
|
||||
pub text_offset: Option<u32>,
|
||||
pub trailing_block_type: Option<EditorBlockType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorMergeWithPrevious {
|
||||
pub block_id: String,
|
||||
pub previous_block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorMoveBlock {
|
||||
pub block_id: String,
|
||||
pub parent_block_id: Option<String>,
|
||||
pub after_block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorIndentBlock {
|
||||
pub block_id: String,
|
||||
pub max_depth: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorOutdentBlock {
|
||||
pub block_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorToggleHeadingCollapse {
|
||||
pub block_id: String,
|
||||
pub collapsed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAttachReferenceToken {
|
||||
pub block_id: String,
|
||||
pub content_node_index: u32,
|
||||
pub token: ReferenceToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorDetachReferenceToken {
|
||||
pub block_id: String,
|
||||
pub content_node_index: u32,
|
||||
pub target_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum EditorCommand {
|
||||
ReplaceBlock(EditorReplaceBlock),
|
||||
InsertBlockAfter(EditorInsertBlockAfter),
|
||||
DeleteBlock(EditorDeleteBlock),
|
||||
SplitBlock(EditorSplitBlock),
|
||||
MergeWithPrevious(EditorMergeWithPrevious),
|
||||
MoveBlock(EditorMoveBlock),
|
||||
IndentBlock(EditorIndentBlock),
|
||||
OutdentBlock(EditorOutdentBlock),
|
||||
ToggleHeadingCollapse(EditorToggleHeadingCollapse),
|
||||
AttachReferenceToken(EditorAttachReferenceToken),
|
||||
DetachReferenceToken(EditorDetachReferenceToken),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorKernelCommandMapping {
|
||||
pub editor_command: EditorCommandKind,
|
||||
pub kernel_command_name: String,
|
||||
pub lossy: bool,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCommandDescriptor {
|
||||
pub kind: EditorCommandKind,
|
||||
pub label: String,
|
||||
pub write_command: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCommandCatalog {
|
||||
#[serde(default)]
|
||||
pub commands: Vec<EditorCommandDescriptor>,
|
||||
#[serde(default)]
|
||||
pub kernel_command_mappings: Vec<EditorKernelCommandMapping>,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::editor::model::{EditorBlockDocument, ReferenceTokenStrategy};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarkdownFlavor {
|
||||
Commonmark,
|
||||
Gfm,
|
||||
Mnote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarkdownImportMode {
|
||||
ReplaceDocument,
|
||||
ReplaceSelection,
|
||||
MergeIntoDocument,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarkdownExportMode {
|
||||
FullDocument,
|
||||
Selection,
|
||||
SingleBlock,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownImportBoundary {
|
||||
pub allow_front_matter: bool,
|
||||
pub allow_html_blocks: bool,
|
||||
pub allow_reference_tokens: bool,
|
||||
pub unsupported_blocks_as_paragraph: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownExportBoundary {
|
||||
pub preserve_heading_collapse: bool,
|
||||
pub preserve_reference_tokens: bool,
|
||||
pub allow_lossy_block_fallback: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownImportOptions {
|
||||
pub mode: MarkdownImportMode,
|
||||
pub flavor: MarkdownFlavor,
|
||||
pub reference_token_strategy: ReferenceTokenStrategy,
|
||||
pub boundary: MarkdownImportBoundary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownExportOptions {
|
||||
pub mode: MarkdownExportMode,
|
||||
pub flavor: MarkdownFlavor,
|
||||
pub reference_token_strategy: ReferenceTokenStrategy,
|
||||
pub boundary: MarkdownExportBoundary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownImportRequest {
|
||||
pub markdown: String,
|
||||
pub options: MarkdownImportOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownImportResult {
|
||||
pub document: EditorBlockDocument,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownExportRequest {
|
||||
pub document: EditorBlockDocument,
|
||||
pub options: MarkdownExportOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarkdownExportResult {
|
||||
pub markdown: String,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
pub mod command;
|
||||
pub mod markdown;
|
||||
pub mod model;
|
||||
|
||||
pub use command::{
|
||||
EditorAttachReferenceToken, EditorCommand, EditorCommandCatalog, EditorCommandDescriptor,
|
||||
EditorCommandKind, EditorDeleteBlock, EditorDetachReferenceToken, EditorIndentBlock,
|
||||
EditorInsertBlockAfter, EditorKernelCommandMapping, EditorMergeWithPrevious, EditorMoveBlock,
|
||||
EditorOutdentBlock, EditorReplaceBlock, EditorSplitBlock, EditorToggleHeadingCollapse,
|
||||
};
|
||||
pub use markdown::{
|
||||
MarkdownExportBoundary, MarkdownExportMode, MarkdownExportOptions, MarkdownExportRequest,
|
||||
MarkdownExportResult, MarkdownFlavor, MarkdownImportBoundary, MarkdownImportMode,
|
||||
MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
|
||||
};
|
||||
pub use model::{
|
||||
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
|
||||
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn editor_command_kind_uses_phase_one_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorCommandKind::InsertBlockAfter).unwrap(),
|
||||
"\"insert_block_after\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorCommandKind::MergeWithPrevious).unwrap(),
|
||||
"\"merge_with_previous\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorCommandKind::ToggleHeadingCollapse).unwrap(),
|
||||
"\"toggle_heading_collapse\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_block_type_uses_expected_snake_case_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorBlockType::BulletListItem).unwrap(),
|
||||
"\"bullet_list_item\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorBlockType::PageReference).unwrap(),
|
||||
"\"page_reference\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorBlockType::BlockReference).unwrap(),
|
||||
"\"block_reference\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorBlockType {
|
||||
Paragraph,
|
||||
Heading,
|
||||
BulletListItem,
|
||||
NumberedListItem,
|
||||
Quote,
|
||||
Todo,
|
||||
CodeBlock,
|
||||
PageReference,
|
||||
BlockReference,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReferenceTokenKind {
|
||||
PageReference,
|
||||
BlockReference,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReferenceTokenStrategy {
|
||||
DoubleBracket,
|
||||
DoubleParen,
|
||||
InlineChip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TextMark {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strike,
|
||||
Code,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReferenceToken {
|
||||
pub kind: ReferenceTokenKind,
|
||||
pub strategy: ReferenceTokenStrategy,
|
||||
pub target_id: String,
|
||||
pub label: Option<String>,
|
||||
pub raw_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentNodePayload {
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
marks: Vec<TextMark>,
|
||||
},
|
||||
HardBreak,
|
||||
ReferenceToken {
|
||||
token: ReferenceToken,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentNode {
|
||||
pub payload: ContentNodePayload,
|
||||
#[serde(default)]
|
||||
pub attrs: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockProps {
|
||||
pub indent: Option<u16>,
|
||||
pub heading_level: Option<u8>,
|
||||
pub checked: Option<bool>,
|
||||
pub collapsed: Option<bool>,
|
||||
pub language: Option<String>,
|
||||
pub reference_target_id: Option<String>,
|
||||
pub reference_label: Option<String>,
|
||||
pub reference_token_strategy: Option<ReferenceTokenStrategy>,
|
||||
#[serde(default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorBlock {
|
||||
pub block_id: String,
|
||||
pub block_type: EditorBlockType,
|
||||
#[serde(default)]
|
||||
pub props: BlockProps,
|
||||
#[serde(default)]
|
||||
pub content_nodes: Vec<ContentNode>,
|
||||
#[serde(default)]
|
||||
pub child_block_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorBlockDocument {
|
||||
pub document_id: String,
|
||||
#[serde(default)]
|
||||
pub root_block_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocks: Vec<EditorBlock>,
|
||||
}
|
||||
@@ -352,6 +352,121 @@ pub struct KernelProjectionResult {
|
||||
pub edges: Vec<KernelEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DocumentReadNodeType {
|
||||
Page,
|
||||
Section,
|
||||
ContentNode,
|
||||
ReferenceAnchor,
|
||||
Mindmap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentReadNodeMeta {
|
||||
pub title: Option<String>,
|
||||
pub text_snippet: Option<String>,
|
||||
pub block_type: Option<String>,
|
||||
pub heading_level: Option<u32>,
|
||||
pub numbering: Option<String>,
|
||||
pub child_count: u32,
|
||||
pub order: u32,
|
||||
#[serde(default)]
|
||||
pub path: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentReadNode {
|
||||
pub id: String,
|
||||
pub parent_node_id: Option<String>,
|
||||
pub node_type: DocumentReadNodeType,
|
||||
pub block_id: Option<String>,
|
||||
pub anchor_block_id: Option<String>,
|
||||
pub depth: u32,
|
||||
pub metadata: DocumentReadNodeMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentReadSubtree {
|
||||
pub root_node_id: String,
|
||||
pub nodes: Vec<DocumentReadNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentReadOutlineEntry {
|
||||
pub id: String,
|
||||
pub node_id: String,
|
||||
pub anchor_block_id: Option<String>,
|
||||
pub title: String,
|
||||
pub level: u32,
|
||||
pub numbering: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DocumentReadEvidenceKind {
|
||||
Page,
|
||||
Heading,
|
||||
Paragraph,
|
||||
List,
|
||||
Todo,
|
||||
Quote,
|
||||
Code,
|
||||
Media,
|
||||
Reference,
|
||||
Table,
|
||||
Mindmap,
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentReadEvidenceItem {
|
||||
pub id: String,
|
||||
pub node_id: String,
|
||||
pub block_id: Option<String>,
|
||||
pub kind: DocumentReadEvidenceKind,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentReadStats {
|
||||
pub block_count: u32,
|
||||
pub heading_count: u32,
|
||||
pub evidence_count: u32,
|
||||
pub max_depth: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentReadPageSubtree {
|
||||
pub projection_id: String,
|
||||
pub projection: String,
|
||||
pub root_node_id: String,
|
||||
pub root_node: DocumentReadNode,
|
||||
pub subtree: DocumentReadSubtree,
|
||||
#[serde(default)]
|
||||
pub outline: Vec<DocumentReadOutlineEntry>,
|
||||
#[serde(default)]
|
||||
pub evidence: Vec<DocumentReadEvidenceItem>,
|
||||
pub stats: DocumentReadStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentContentResult {
|
||||
pub content: Value,
|
||||
pub revision: u64,
|
||||
pub conflict_detection_key: String,
|
||||
pub title: Option<String>,
|
||||
pub page_subtree: DocumentReadPageSubtree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelCreateNode {
|
||||
@@ -508,7 +623,10 @@ mod tests {
|
||||
assert_eq!(value["rowId"], json!("asset:page_1"));
|
||||
assert_eq!(value["rowKind"], json!("asset"));
|
||||
assert_eq!(value["projectionKind"], json!("file_tree"));
|
||||
assert_eq!(value["capabilities"], json!(["expand", "open", "create-child"]));
|
||||
assert_eq!(
|
||||
value["capabilities"],
|
||||
json!(["expand", "open", "create-child"])
|
||||
);
|
||||
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
|
||||
assert_eq!(value["iconHint"], json!("page"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod command;
|
||||
pub mod common;
|
||||
pub mod editor;
|
||||
pub mod governance;
|
||||
pub mod kernel;
|
||||
pub mod mindmap;
|
||||
@@ -17,39 +18,49 @@ pub use common::{
|
||||
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
||||
SourcePayload, TargetRef,
|
||||
};
|
||||
pub use editor::{
|
||||
BlockProps, ContentNode, ContentNodePayload, EditorAttachReferenceToken, EditorBlock,
|
||||
EditorBlockDocument, EditorBlockType, EditorCommand, EditorCommandCatalog,
|
||||
EditorCommandDescriptor, EditorCommandKind, EditorDeleteBlock, EditorDetachReferenceToken,
|
||||
EditorIndentBlock, EditorInsertBlockAfter, EditorKernelCommandMapping, EditorMergeWithPrevious,
|
||||
EditorMoveBlock, EditorOutdentBlock, EditorReplaceBlock, EditorSplitBlock,
|
||||
EditorToggleHeadingCollapse, MarkdownExportBoundary, MarkdownExportMode, MarkdownExportOptions,
|
||||
MarkdownExportRequest, MarkdownExportResult, MarkdownFlavor, MarkdownImportBoundary,
|
||||
MarkdownImportMode, MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
|
||||
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
||||
};
|
||||
pub use kernel::{
|
||||
KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode,
|
||||
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
|
||||
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
|
||||
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
|
||||
KernelNodeType, KernelProjectionAssetKind, KernelProjectionCapability,
|
||||
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
||||
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
||||
DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
|
||||
KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult,
|
||||
KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
||||
};
|
||||
pub use mindmap::{
|
||||
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode,
|
||||
};
|
||||
pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode};
|
||||
pub use query::{
|
||||
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks,
|
||||
ListSidebarDataset, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset,
|
||||
QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||
};
|
||||
pub use tool::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
||||
DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOCS_TOOLSET_READ, DOC_TOOL_FIND, DOC_TOOL_GET,
|
||||
DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||
INDEX_TOOL_REBUILD, MINDMAP_TOOL_APPLY_OPS,
|
||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
||||
MINDMAP_TOOL_GET_SUBTREE,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ,
|
||||
MINDMAP_TOOLSET_WRITE, OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||
INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
|
||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
|
||||
ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, ONLYOFFICE_TOOLSET_SERVICE,
|
||||
RECOVERY_TOOLSET_JOB, REPLAY_TOOL_EVENTS,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, RECOVERY_TOOLSET_JOB,
|
||||
REPLAY_TOOL_EVENTS,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -164,9 +175,15 @@ mod tests {
|
||||
.toolset("toolset.doc_write")
|
||||
.expect("doc_write toolset should exist");
|
||||
assert!(doc_write.write_toolset);
|
||||
assert_eq!(doc_write.tool_names, &["doc_insert_blocks", "doc_replace_range"]);
|
||||
assert_eq!(
|
||||
registry.tool("doc_get").expect("doc_get should exist").toolset_id,
|
||||
doc_write.tool_names,
|
||||
&["doc_insert_blocks", "doc_replace_range"]
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.tool("doc_get")
|
||||
.expect("doc_get should exist")
|
||||
.toolset_id,
|
||||
"toolset.doc_read"
|
||||
);
|
||||
let mindmap_write = registry
|
||||
@@ -203,7 +220,11 @@ mod tests {
|
||||
assert!(!observe.write_toolset);
|
||||
assert_eq!(
|
||||
observe.tool_names,
|
||||
&["bridge_request_get", "bridge_trace_get", "bridge_command_get"]
|
||||
&[
|
||||
"bridge_request_get",
|
||||
"bridge_trace_get",
|
||||
"bridge_command_get"
|
||||
]
|
||||
);
|
||||
let recovery = registry
|
||||
.toolset("toolset.recovery_job")
|
||||
|
||||
@@ -71,8 +71,7 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> {
|
||||
self.toolset(id)
|
||||
.map(|toolset| toolset.tool_names.to_vec())
|
||||
self.toolset(id).map(|toolset| toolset.tool_names.to_vec())
|
||||
}
|
||||
|
||||
pub fn tools_in_set(&self, id: &str) -> Option<Vec<&'static ToolSpec>> {
|
||||
@@ -105,8 +104,7 @@ pub const SEARCH_WEB_TOOL: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||
@@ -117,11 +115,9 @@ pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
};
|
||||
|
||||
|
||||
pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
name: "docs_search",
|
||||
display_name: "跨页文档搜索",
|
||||
@@ -130,8 +126,7 @@ pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
||||
@@ -142,8 +137,7 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
||||
};
|
||||
|
||||
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||
@@ -154,8 +148,7 @@ pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
||||
@@ -166,8 +159,7 @@ pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
||||
@@ -178,8 +170,7 @@ pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
|
||||
};
|
||||
|
||||
pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
||||
@@ -190,8 +181,7 @@ pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
||||
@@ -202,8 +192,7 @@ pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
||||
input_schema_json: r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
||||
@@ -214,8 +203,7 @@ pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
||||
@@ -226,8 +214,7 @@ pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
||||
@@ -238,8 +225,7 @@ pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
||||
@@ -250,8 +236,7 @@ pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
||||
@@ -262,8 +247,7 @@ pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
|
||||
@@ -274,8 +258,7 @@ pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
||||
@@ -286,8 +269,7 @@ pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
||||
@@ -298,8 +280,7 @@ pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
||||
@@ -310,8 +291,7 @@ pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
||||
@@ -322,8 +302,7 @@ pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
||||
@@ -334,8 +313,7 @@ pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
||||
@@ -346,8 +324,7 @@ pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
||||
@@ -358,8 +335,7 @@ pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
||||
@@ -370,8 +346,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
||||
@@ -382,8 +357,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
||||
@@ -394,8 +368,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
@@ -406,7 +379,6 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
tool_names: &["doc_get", "doc_find"],
|
||||
};
|
||||
|
||||
|
||||
pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.docs_read",
|
||||
display_name: "跨页文档读取",
|
||||
@@ -466,7 +438,11 @@ pub const OBSERVE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
display_name: "统一观测",
|
||||
description: "按 request、trace、command 回查统一日志与事件视图。",
|
||||
write_toolset: false,
|
||||
tool_names: &["bridge_request_get", "bridge_trace_get", "bridge_command_get"],
|
||||
tool_names: &[
|
||||
"bridge_request_get",
|
||||
"bridge_trace_get",
|
||||
"bridge_command_get",
|
||||
],
|
||||
};
|
||||
|
||||
pub const RECOVERY_TOOLSET_JOB: ToolSetSpec = ToolSetSpec {
|
||||
|
||||
@@ -273,7 +273,9 @@ pub fn search_pages(
|
||||
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
|
||||
)
|
||||
})
|
||||
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
|
||||
.filter(|document| {
|
||||
workspace_id.map_or(true, |workspace| document.workspace_id == workspace)
|
||||
})
|
||||
.filter(|document| {
|
||||
document
|
||||
.title
|
||||
@@ -376,7 +378,8 @@ pub fn evaluate_search_documents(
|
||||
request.custom_range_to.as_deref(),
|
||||
),
|
||||
_ => within_range(
|
||||
document.updated_at
|
||||
document
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(document.created_at.as_deref()),
|
||||
boundary_iso.as_deref(),
|
||||
@@ -386,7 +389,10 @@ pub fn evaluate_search_documents(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let eligible_doc_ids: HashSet<&str> = eligible_docs.iter().map(|document| document.id.as_str()).collect();
|
||||
let eligible_doc_ids: HashSet<&str> = eligible_docs
|
||||
.iter()
|
||||
.map(|document| document.id.as_str())
|
||||
.collect();
|
||||
let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
|
||||
.iter()
|
||||
.map(|document| (document.id.as_str(), *document))
|
||||
@@ -511,7 +517,10 @@ pub fn evaluate_search_documents(
|
||||
}
|
||||
|
||||
let file_name = asset.file_name.as_deref().unwrap_or("").trim();
|
||||
if !request.title_only && !file_name.is_empty() && file_name.to_lowercase().contains(&normalized_lower) {
|
||||
if !request.title_only
|
||||
&& !file_name.is_empty()
|
||||
&& file_name.to_lowercase().contains(&normalized_lower)
|
||||
{
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&asset.document_id,
|
||||
@@ -552,7 +561,14 @@ pub fn evaluate_search_documents(
|
||||
score: 1.7,
|
||||
match_field: SearchMatchField::Content,
|
||||
snippet: build_snippet(
|
||||
&format!("附件:{}\n{ocr_text}", if file_name.is_empty() { asset.id.as_str() } else { file_name }),
|
||||
&format!(
|
||||
"附件:{}\n{ocr_text}",
|
||||
if file_name.is_empty() {
|
||||
asset.id.as_str()
|
||||
} else {
|
||||
file_name
|
||||
}
|
||||
),
|
||||
normalized_query,
|
||||
),
|
||||
has_ocr: true,
|
||||
@@ -798,7 +814,12 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
|
||||
}
|
||||
|
||||
fn normalize_text(value: &str) -> String {
|
||||
value.split_whitespace().collect::<Vec<_>>().join(" ").trim().to_string()
|
||||
value
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
@@ -1090,7 +1111,10 @@ mod tests {
|
||||
|
||||
assert_eq!(result.results.len(), 1);
|
||||
assert_eq!(result.results[0].id, "page_1");
|
||||
assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
|
||||
assert!(
|
||||
result.results[0].snippet.contains("<mark>Rust</mark>")
|
||||
|| result.results[0].snippet.contains("<mark>rust</mark>")
|
||||
);
|
||||
assert_eq!(result.results[0].node_id.as_deref(), Some("page_1"));
|
||||
assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1"));
|
||||
assert_eq!(result.results[0].evidence.len(), 1);
|
||||
|
||||
@@ -10,6 +10,7 @@ base64 = "0.22"
|
||||
bridge-runtime = { path = "../bridge-runtime" }
|
||||
clap = { version = "4.5.38", features = ["derive"] }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -3,6 +3,7 @@ use bridge_runtime::{
|
||||
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
|
||||
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use core_protocol::{
|
||||
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
|
||||
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
|
||||
@@ -10,6 +11,11 @@ use core_protocol::{
|
||||
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
|
||||
SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
|
||||
};
|
||||
use mnote_editor_core::{
|
||||
apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock,
|
||||
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
|
||||
VisibilitySnapshot,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -130,6 +136,80 @@ pub struct CliTransportPlan {
|
||||
pub args_json: Value,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageCreateArgs<'a> {
|
||||
pub page_id: &'a str,
|
||||
@@ -194,6 +274,8 @@ pub fn supported_command_surface() -> Vec<&'static str> {
|
||||
"search documents",
|
||||
"search blocks",
|
||||
"sidebar dataset",
|
||||
"editor markdown-roundtrip",
|
||||
"editor session-demo",
|
||||
"tool run",
|
||||
];
|
||||
for tool_name in default_tool_registry().tool_names() {
|
||||
@@ -223,6 +305,8 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
|
||||
"search.documents",
|
||||
"search.blocks",
|
||||
"sidebar.dataset",
|
||||
"editor.markdown_roundtrip",
|
||||
"editor.session_demo",
|
||||
"tool.run",
|
||||
];
|
||||
for tool_name in default_tool_registry().tool_names() {
|
||||
@@ -231,6 +315,179 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
|
||||
contracts
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_plain_output(output: &CliJsonOutput) -> String {
|
||||
match &output.operation {
|
||||
CliOperationOutput::Command {
|
||||
@@ -2359,6 +2616,10 @@ fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
|
||||
CliError::validation(error.message)
|
||||
}
|
||||
|
||||
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
|
||||
CliError::validation(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2374,9 +2635,121 @@ mod tests {
|
||||
assert!(commands.contains(&"mindmap get"));
|
||||
assert!(commands.contains(&"search documents"));
|
||||
assert!(commands.contains(&"sidebar dataset"));
|
||||
assert!(commands.contains(&"editor markdown-roundtrip"));
|
||||
assert!(commands.contains(&"editor session-demo"));
|
||||
assert!(commands.contains(&"tool run"));
|
||||
}
|
||||
|
||||
#[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()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_save_json_contract_uses_documents_save() {
|
||||
let output = plan_page_save(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use core_protocol::{InvocationKind, ToolExecutionMode};
|
||||
use mnote_cli::{
|
||||
execute_output, plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch,
|
||||
plan_mindmap_get, plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete,
|
||||
plan_page_get, plan_page_move, plan_page_restore, plan_page_save, plan_page_title,
|
||||
plan_search_blocks, plan_search_documents, plan_sidebar_dataset, plan_tool_run,
|
||||
editor_ai_pipeline, editor_markdown_roundtrip, editor_session_demo, execute_output,
|
||||
plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch, plan_mindmap_get,
|
||||
plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete, plan_page_get,
|
||||
plan_page_move, plan_page_restore, plan_page_save, plan_page_title, plan_search_blocks,
|
||||
plan_search_documents, plan_sidebar_dataset, plan_tool_run, render_editor_plain_output,
|
||||
render_plain_output, BlockEmbedArgs, BlockMoveArgs, CliContext, CliError, CliJsonOutput,
|
||||
PageCreateArgs, PageDeleteArgs, PageMoveArgs, PageRestoreArgs,
|
||||
EditorAiScenarioArg, EditorCliOutput, EditorInputKindArg, PageCreateArgs, PageDeleteArgs,
|
||||
PageMoveArgs, PageRestoreArgs,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -57,6 +59,7 @@ enum Commands {
|
||||
Mindmap(MindmapCommand),
|
||||
Search(SearchCommand),
|
||||
Sidebar(SidebarCommand),
|
||||
Editor(EditorCliCommand),
|
||||
Tool(ToolCommand),
|
||||
}
|
||||
|
||||
@@ -370,6 +373,43 @@ struct SidebarDatasetArgs {
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct EditorCliCommand {
|
||||
#[command(subcommand)]
|
||||
action: EditorAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum EditorAction {
|
||||
MarkdownRoundtrip(EditorMarkdownRoundtripArgs),
|
||||
SessionDemo(EditorSessionDemoArgs),
|
||||
AiPipeline(EditorAiPipelineArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct EditorMarkdownRoundtripArgs {
|
||||
#[arg(long)]
|
||||
markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct EditorSessionDemoArgs {
|
||||
#[arg(long)]
|
||||
markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct EditorAiPipelineArgs {
|
||||
#[arg(long, value_enum, default_value_t = EditorInputKindArg::PlainText)]
|
||||
kind: EditorInputKindArg,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = EditorAiScenarioArg::MeetingNotesToTodos)]
|
||||
scenario: EditorAiScenarioArg,
|
||||
|
||||
#[arg(long)]
|
||||
input: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct ToolCommand {
|
||||
#[command(subcommand)]
|
||||
@@ -422,6 +462,11 @@ fn main() {
|
||||
dry_run: cli.global.dry_run,
|
||||
};
|
||||
|
||||
enum CommandOutcome {
|
||||
Bridge(CliJsonOutput),
|
||||
Editor(EditorCliOutput),
|
||||
}
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Page(command) => match command.action {
|
||||
PageAction::Get(args) => {
|
||||
@@ -475,7 +520,8 @@ fn main() {
|
||||
workspace_id: args.workspace_id.as_deref(),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Block(command) => match command.action {
|
||||
BlockAction::Insert(args) => plan_block_insert(
|
||||
&context,
|
||||
@@ -512,7 +558,8 @@ fn main() {
|
||||
target_block_id: args.target_block_id.as_deref(),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Mindmap(command) => match command.action {
|
||||
MindmapAction::Get(args) => plan_mindmap_get(
|
||||
&context,
|
||||
@@ -535,7 +582,8 @@ fn main() {
|
||||
&args.mindmap_id,
|
||||
&args.ops_json,
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Search(command) => match command.action {
|
||||
SearchAction::Documents(args) => plan_search_documents(
|
||||
&context,
|
||||
@@ -551,10 +599,22 @@ fn main() {
|
||||
args.limit,
|
||||
args.cursor.as_deref(),
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Sidebar(command) => match command.action {
|
||||
SidebarAction::Dataset(args) => plan_sidebar_dataset(&context, &args.workspace_id),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Editor(command) => match command.action {
|
||||
EditorAction::MarkdownRoundtrip(args) => editor_markdown_roundtrip(&args.markdown),
|
||||
EditorAction::SessionDemo(args) => editor_session_demo(&args.markdown),
|
||||
EditorAction::AiPipeline(args) => editor_ai_pipeline(
|
||||
to_editor_input_kind(args.kind),
|
||||
to_editor_ai_scenario(args.scenario),
|
||||
&args.input,
|
||||
),
|
||||
}
|
||||
.map(CommandOutcome::Editor),
|
||||
Commands::Tool(command) => match command.action {
|
||||
ToolAction::Run(args) => plan_tool_run(
|
||||
&context,
|
||||
@@ -563,18 +623,23 @@ fn main() {
|
||||
to_execution_mode(args.mode),
|
||||
&args.args_json,
|
||||
),
|
||||
},
|
||||
}
|
||||
.and_then(|output| {
|
||||
if cli.global.execute {
|
||||
execute_output(&output, &context)
|
||||
} else {
|
||||
Ok(output)
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
}
|
||||
.and_then(|output| match output {
|
||||
CommandOutcome::Bridge(output) => {
|
||||
if cli.global.execute {
|
||||
execute_output(&output, &context).map(CommandOutcome::Bridge)
|
||||
} else {
|
||||
Ok(CommandOutcome::Bridge(output))
|
||||
}
|
||||
}
|
||||
CommandOutcome::Editor(output) => Ok(CommandOutcome::Editor(output)),
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(output) => emit_success(&output, cli.global.json),
|
||||
Ok(CommandOutcome::Bridge(output)) => emit_success(&output, cli.global.json),
|
||||
Ok(CommandOutcome::Editor(output)) => emit_editor_success(&output, cli.global.json),
|
||||
Err(error) => emit_error(&error, cli.global.json),
|
||||
}
|
||||
}
|
||||
@@ -590,6 +655,17 @@ fn emit_success(output: &CliJsonOutput, json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_editor_success(output: &EditorCliOutput, json_mode: bool) {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(output).expect("editor CLI JSON 输出必须可序列化")
|
||||
);
|
||||
} else {
|
||||
println!("{}", render_editor_plain_output(output));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_error(error: &CliError, json_mode: bool) -> ! {
|
||||
if json_mode {
|
||||
println!(
|
||||
@@ -625,3 +701,22 @@ fn to_execution_mode(mode: ToolModeArg) -> ToolExecutionMode {
|
||||
ToolModeArg::ExplainPlan => ToolExecutionMode::ExplainPlan,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_editor_input_kind(kind: EditorInputKindArg) -> mnote_editor_core::EditorInputKind {
|
||||
match kind {
|
||||
EditorInputKindArg::PlainText => mnote_editor_core::EditorInputKind::PlainText,
|
||||
EditorInputKindArg::Markdown => mnote_editor_core::EditorInputKind::Markdown,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_editor_ai_scenario(scenario: EditorAiScenarioArg) -> mnote_editor_core::EditorAiScenario {
|
||||
match scenario {
|
||||
EditorAiScenarioArg::MeetingNotesToTodos => {
|
||||
mnote_editor_core::EditorAiScenario::MeetingNotesToTodos
|
||||
}
|
||||
EditorAiScenarioArg::LongParagraphToTitle => {
|
||||
mnote_editor_core::EditorAiScenario::LongParagraphToTitle
|
||||
}
|
||||
EditorAiScenarioArg::PageReorder => mnote_editor_core::EditorAiScenario::PageReorder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "mnote-editor-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -0,0 +1,292 @@
|
||||
use crate::error::{CoreError, CoreResult};
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditorCommand {
|
||||
SetBlockType {
|
||||
block_id: String,
|
||||
block_type: BlockType,
|
||||
},
|
||||
ReplaceBlock {
|
||||
block_id: String,
|
||||
text: String,
|
||||
},
|
||||
InsertBlockAfter {
|
||||
after_block_id: Option<String>,
|
||||
block: DocumentBlock,
|
||||
},
|
||||
DeleteBlock {
|
||||
block_id: String,
|
||||
},
|
||||
SplitBlock {
|
||||
block_id: String,
|
||||
offset: usize,
|
||||
new_block_id: String,
|
||||
},
|
||||
MergeWithPrevious {
|
||||
block_id: String,
|
||||
},
|
||||
MoveBlock {
|
||||
block_id: String,
|
||||
after_block_id: Option<String>,
|
||||
},
|
||||
IndentBlock {
|
||||
block_id: String,
|
||||
},
|
||||
OutdentBlock {
|
||||
block_id: String,
|
||||
},
|
||||
ToggleHeadingCollapse {
|
||||
block_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct CommandExecutor;
|
||||
|
||||
impl CommandExecutor {
|
||||
pub fn apply(document: &mut DocumentModel, command: EditorCommand) -> CoreResult<()> {
|
||||
match command {
|
||||
EditorCommand::SetBlockType {
|
||||
block_id,
|
||||
block_type,
|
||||
} => set_block_type(document, &block_id, block_type),
|
||||
EditorCommand::ReplaceBlock { block_id, text } => {
|
||||
replace_block(document, &block_id, text)
|
||||
}
|
||||
EditorCommand::InsertBlockAfter {
|
||||
after_block_id,
|
||||
block,
|
||||
} => insert_block_after(document, after_block_id.as_deref(), block),
|
||||
EditorCommand::DeleteBlock { block_id } => delete_block(document, &block_id),
|
||||
EditorCommand::SplitBlock {
|
||||
block_id,
|
||||
offset,
|
||||
new_block_id,
|
||||
} => split_block(document, &block_id, offset, new_block_id),
|
||||
EditorCommand::MergeWithPrevious { block_id } => {
|
||||
merge_with_previous(document, &block_id)
|
||||
}
|
||||
EditorCommand::MoveBlock {
|
||||
block_id,
|
||||
after_block_id,
|
||||
} => move_block(document, &block_id, after_block_id.as_deref()),
|
||||
EditorCommand::IndentBlock { block_id } => indent_block(document, &block_id),
|
||||
EditorCommand::OutdentBlock { block_id } => outdent_block(document, &block_id),
|
||||
EditorCommand::ToggleHeadingCollapse { block_id } => {
|
||||
toggle_heading_collapse(document, &block_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_block_type(
|
||||
document: &mut DocumentModel,
|
||||
block_id: &str,
|
||||
block_type: BlockType,
|
||||
) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
block.block_type = block_type;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_block(document: &mut DocumentModel, block_id: &str, text: String) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
block.content.text = text;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_block_after(
|
||||
document: &mut DocumentModel,
|
||||
after_block_id: Option<&str>,
|
||||
block: DocumentBlock,
|
||||
) -> CoreResult<()> {
|
||||
if document.contains_id(&block.id) {
|
||||
return Err(CoreError::DuplicateBlockId(block.id));
|
||||
}
|
||||
|
||||
let insert_index = match after_block_id {
|
||||
Some(target_id) => {
|
||||
let range = document
|
||||
.subtree_range(target_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
|
||||
*range.end() + 1
|
||||
}
|
||||
None => document.blocks().len(),
|
||||
};
|
||||
document.blocks_mut().insert(insert_index, block);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
document.blocks_mut().drain(range);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_block(
|
||||
document: &mut DocumentModel,
|
||||
block_id: &str,
|
||||
offset: usize,
|
||||
new_block_id: String,
|
||||
) -> CoreResult<()> {
|
||||
if document.contains_id(&new_block_id) {
|
||||
return Err(CoreError::DuplicateBlockId(new_block_id));
|
||||
}
|
||||
let index = document
|
||||
.index_of(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let block = document.blocks()[index].clone();
|
||||
let split_at = byte_index_for_char(&block.content.text, offset).ok_or_else(|| {
|
||||
CoreError::InvalidSplitOffset {
|
||||
block_id: block_id.to_string(),
|
||||
offset,
|
||||
}
|
||||
})?;
|
||||
let left = block.content.text[..split_at].to_string();
|
||||
let right = block.content.text[split_at..].to_string();
|
||||
|
||||
{
|
||||
let current = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
current.content.text = left;
|
||||
}
|
||||
|
||||
let mut next_block = block;
|
||||
next_block.id = new_block_id;
|
||||
next_block.content.text = right;
|
||||
next_block.collapsed = false;
|
||||
document.blocks_mut().insert(*range.end() + 1, next_block);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_with_previous(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let index = document
|
||||
.index_of(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
if index == 0 {
|
||||
return Err(CoreError::InvalidOperation("首块无法与上一块合并"));
|
||||
}
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
if *range.start() != *range.end() {
|
||||
return Err(CoreError::InvalidOperation(
|
||||
"当前最小实现不支持带子树的 merge",
|
||||
));
|
||||
}
|
||||
|
||||
let previous_index = index - 1;
|
||||
let text_to_append = document.blocks()[index].content.text.clone();
|
||||
document.blocks_mut()[previous_index]
|
||||
.content
|
||||
.text
|
||||
.push_str(&text_to_append);
|
||||
document.blocks_mut().remove(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_block(
|
||||
document: &mut DocumentModel,
|
||||
block_id: &str,
|
||||
after_block_id: Option<&str>,
|
||||
) -> CoreResult<()> {
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let start = *range.start();
|
||||
let end = *range.end();
|
||||
let moved_ids: Vec<String> = document.blocks()[start..=end]
|
||||
.iter()
|
||||
.map(|block| block.id.clone())
|
||||
.collect();
|
||||
if let Some(target_id) = after_block_id {
|
||||
if moved_ids.iter().any(|id| id == target_id) {
|
||||
return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面"));
|
||||
}
|
||||
}
|
||||
|
||||
let moved_blocks: Vec<DocumentBlock> = document.blocks_mut().drain(start..=end).collect();
|
||||
let insert_index = match after_block_id {
|
||||
Some(target_id) => {
|
||||
let target_range = document
|
||||
.subtree_range(target_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
|
||||
*target_range.end() + 1
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
document
|
||||
.blocks_mut()
|
||||
.splice(insert_index..insert_index, moved_blocks);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn indent_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let index = document
|
||||
.index_of(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
if index == 0 {
|
||||
return Err(CoreError::InvalidOperation("首块无法缩进"));
|
||||
}
|
||||
let previous_id = document.blocks()[index - 1].id.clone();
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
for position in *range.start()..=*range.end() {
|
||||
document.blocks_mut()[position].indent += 1;
|
||||
}
|
||||
document.blocks_mut()[*range.start()].parent_id = Some(previous_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn outdent_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block(block_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let parent_id = block
|
||||
.parent_id
|
||||
.clone()
|
||||
.ok_or(CoreError::InvalidOperation("当前块已在根层级"))?;
|
||||
let next_parent_id = document
|
||||
.block(&parent_id)
|
||||
.and_then(|parent| parent.parent_id.clone());
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
for position in *range.start()..=*range.end() {
|
||||
let current_indent = document.blocks()[position].indent;
|
||||
if current_indent == 0 {
|
||||
return Err(CoreError::InvalidOperation("根层级块不能继续反缩进"));
|
||||
}
|
||||
document.blocks_mut()[position].indent = current_indent - 1;
|
||||
}
|
||||
document.blocks_mut()[*range.start()].parent_id = next_parent_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_heading_collapse(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
block.collapsed = !block.collapsed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn byte_index_for_char(text: &str, offset: usize) -> Option<usize> {
|
||||
if offset == text.chars().count() {
|
||||
return Some(text.len());
|
||||
}
|
||||
text.char_indices().nth(offset).map(|(index, _)| index)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CoreError {
|
||||
BlockNotFound(String),
|
||||
DuplicateBlockId(String),
|
||||
InvalidOperation(&'static str),
|
||||
InvalidSplitOffset { block_id: String, offset: usize },
|
||||
MarkdownParse(String),
|
||||
}
|
||||
|
||||
impl Display for CoreError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BlockNotFound(block_id) => write!(f, "未找到块: {block_id}"),
|
||||
Self::DuplicateBlockId(block_id) => write!(f, "块 id 已存在: {block_id}"),
|
||||
Self::InvalidOperation(message) => write!(f, "无效操作: {message}"),
|
||||
Self::InvalidSplitOffset { block_id, offset } => {
|
||||
write!(f, "块 {block_id} 的拆分位置无效: {offset}")
|
||||
}
|
||||
Self::MarkdownParse(message) => write!(f, "Markdown 解析失败: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CoreError {}
|
||||
|
||||
pub type CoreResult<T> = Result<T, CoreError>;
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::command::{CommandExecutor, EditorCommand};
|
||||
use crate::error::CoreResult;
|
||||
use crate::model::DocumentModel;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorSession {
|
||||
document: DocumentModel,
|
||||
undo_stack: Vec<DocumentModel>,
|
||||
redo_stack: Vec<DocumentModel>,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
pub fn new(document: DocumentModel) -> Self {
|
||||
Self {
|
||||
document,
|
||||
undo_stack: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn document(&self) -> &DocumentModel {
|
||||
&self.document
|
||||
}
|
||||
|
||||
pub fn apply_command(&mut self, command: EditorCommand) -> CoreResult<()> {
|
||||
let snapshot = self.document.clone();
|
||||
CommandExecutor::apply(&mut self.document, command)?;
|
||||
self.undo_stack.push(snapshot);
|
||||
self.redo_stack.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some(previous) = self.undo_stack.pop() else {
|
||||
return false;
|
||||
};
|
||||
self.redo_stack.push(self.document.clone());
|
||||
self.document = previous;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some(next) = self.redo_stack.pop() else {
|
||||
return false;
|
||||
};
|
||||
self.undo_stack.push(self.document.clone());
|
||||
self.document = next;
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub mod command;
|
||||
pub mod error;
|
||||
pub mod history;
|
||||
pub mod markdown;
|
||||
pub mod model;
|
||||
pub mod pipeline;
|
||||
pub mod projection;
|
||||
|
||||
pub use command::{CommandExecutor, EditorCommand};
|
||||
pub use error::{CoreError, CoreResult};
|
||||
pub use history::EditorSession;
|
||||
pub use markdown::{export_markdown, import_markdown, import_plain_text};
|
||||
pub use model::{
|
||||
BlockContent, BlockReference, BlockType, DocumentBlock, DocumentModel, ReferenceKind,
|
||||
};
|
||||
pub use pipeline::{
|
||||
apply_ai_pipeline, BlockSnapshot, CommandAuditRecord, EditorAiScenario, EditorChangeReport,
|
||||
EditorInputKind, EditorPipelineRequest, EditorPipelineResult,
|
||||
};
|
||||
pub use projection::{OutlineEntry, VisibilitySnapshot};
|
||||
@@ -0,0 +1,263 @@
|
||||
use crate::error::{CoreError, CoreResult};
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel, ReferenceKind};
|
||||
|
||||
pub fn import_markdown(input: &str) -> CoreResult<DocumentModel> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut ancestry: Vec<String> = Vec::new();
|
||||
let mut next_id = 1usize;
|
||||
let mut lines = input.lines().peekable();
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.trim_start().starts_with("```") {
|
||||
let language = line
|
||||
.trim_start()
|
||||
.trim_start_matches("```")
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut code_lines = Vec::new();
|
||||
let mut closed = false;
|
||||
for next in lines.by_ref() {
|
||||
if next.trim_start().starts_with("```") {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
code_lines.push(next.to_string());
|
||||
}
|
||||
if !closed {
|
||||
return Err(CoreError::MarkdownParse("代码块缺少结束围栏".into()));
|
||||
}
|
||||
ancestry.clear();
|
||||
let mut block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::CodeBlock)
|
||||
.with_text(code_lines.join("\n"));
|
||||
if !language.is_empty() {
|
||||
block = block.with_language(language);
|
||||
}
|
||||
blocks.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
let indent = count_indent(line);
|
||||
while ancestry.len() > indent as usize {
|
||||
ancestry.pop();
|
||||
}
|
||||
|
||||
let trimmed = line.trim_start();
|
||||
let mut block = if let Some((level, title)) = parse_heading(trimmed) {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Heading)
|
||||
.with_heading_level(level)
|
||||
.with_text(title)
|
||||
} else if let Some((checked, text)) = parse_todo(trimmed) {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Todo)
|
||||
.with_checked(checked)
|
||||
.with_text(text)
|
||||
} else if let Some(text) = parse_bullet(trimmed) {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BulletListItem)
|
||||
.with_text(text)
|
||||
} else if let Some(text) = parse_numbered(trimmed) {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::NumberedListItem)
|
||||
.with_text(text)
|
||||
} else if let Some(text) = trimmed.strip_prefix("> ") {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Quote).with_text(text)
|
||||
} else if trimmed == "---" {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Divider)
|
||||
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "[[", "]]") {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::PageReference)
|
||||
.with_reference(ReferenceKind::Page, target_id, label)
|
||||
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "((", "))") {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BlockReference)
|
||||
.with_reference(ReferenceKind::Block, target_id, label)
|
||||
} else {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph).with_text(trimmed)
|
||||
};
|
||||
|
||||
if indent > 0 {
|
||||
let parent_id = ancestry
|
||||
.last()
|
||||
.cloned()
|
||||
.ok_or(CoreError::MarkdownParse("缩进层级缺少父块".into()))?;
|
||||
block = block.with_parent(parent_id, indent);
|
||||
}
|
||||
ancestry.push(block.id.clone());
|
||||
blocks.push(block);
|
||||
}
|
||||
|
||||
Ok(DocumentModel::new(blocks))
|
||||
}
|
||||
|
||||
pub fn import_plain_text(input: &str) -> CoreResult<DocumentModel> {
|
||||
let mut blocks = Vec::new();
|
||||
let normalized = input.replace("\r\n", "\n");
|
||||
let mut next_id = 1usize;
|
||||
for paragraph in normalized
|
||||
.split("\n\n")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph)
|
||||
.with_text(paragraph.replace('\n', " "));
|
||||
blocks.push(block);
|
||||
}
|
||||
Ok(DocumentModel::new(blocks))
|
||||
}
|
||||
|
||||
pub fn export_markdown(document: &DocumentModel) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for block in document.blocks() {
|
||||
let indent = " ".repeat(block.indent as usize);
|
||||
match block.block_type {
|
||||
BlockType::Heading => {
|
||||
let level = block.heading_level.unwrap_or(1).clamp(1, 6);
|
||||
lines.push(format!(
|
||||
"{indent}{} {}",
|
||||
"#".repeat(level as usize),
|
||||
block.content.text
|
||||
));
|
||||
}
|
||||
BlockType::BulletListItem => {
|
||||
lines.push(format!("{indent}- {}", block.content.text));
|
||||
}
|
||||
BlockType::NumberedListItem => {
|
||||
lines.push(format!("{indent}1. {}", block.content.text));
|
||||
}
|
||||
BlockType::Todo => {
|
||||
let marker = if block.checked.unwrap_or(false) {
|
||||
"x"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
lines.push(format!("{indent}- [{marker}] {}", block.content.text));
|
||||
}
|
||||
BlockType::Quote => {
|
||||
lines.push(format!("{indent}> {}", block.content.text));
|
||||
}
|
||||
BlockType::Divider => {
|
||||
lines.push(format!("{indent}---"));
|
||||
}
|
||||
BlockType::CodeBlock => {
|
||||
let fence = match block.content.language.as_deref() {
|
||||
Some(language) if !language.is_empty() => format!("{indent}```{language}"),
|
||||
_ => format!("{indent}```"),
|
||||
};
|
||||
lines.push(fence);
|
||||
for code_line in block.content.text.lines() {
|
||||
lines.push(format!("{indent}{code_line}"));
|
||||
}
|
||||
lines.push(format!("{indent}```"));
|
||||
}
|
||||
BlockType::PageReference => {
|
||||
lines.push(format!(
|
||||
"{indent}{}",
|
||||
render_reference_token(block.content.reference.as_ref(), "[[", "]]")
|
||||
));
|
||||
}
|
||||
BlockType::BlockReference => {
|
||||
lines.push(format!(
|
||||
"{indent}{}",
|
||||
render_reference_token(block.content.reference.as_ref(), "((", "))")
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
lines.push(format!("{indent}{}", block.content.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn next_block_id(next_id: &mut usize) -> String {
|
||||
let current = *next_id;
|
||||
*next_id += 1;
|
||||
format!("imported_{current}")
|
||||
}
|
||||
|
||||
fn count_indent(line: &str) -> u16 {
|
||||
let mut spaces = 0usize;
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
' ' => spaces += 1,
|
||||
'\t' => spaces += 2,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(spaces / 2) as u16
|
||||
}
|
||||
|
||||
fn parse_heading(line: &str) -> Option<(u8, &str)> {
|
||||
let level = line.chars().take_while(|ch| *ch == '#').count();
|
||||
if level == 0 || level > 6 {
|
||||
return None;
|
||||
}
|
||||
let title = line.get(level + 1..)?;
|
||||
if !line.as_bytes().get(level).is_some_and(|ch| *ch == b' ') {
|
||||
return None;
|
||||
}
|
||||
Some((level as u8, title))
|
||||
}
|
||||
|
||||
fn parse_todo(line: &str) -> Option<(bool, &str)> {
|
||||
if let Some(rest) = line.strip_prefix("- [ ] ") {
|
||||
return Some((false, rest));
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("- [x] ") {
|
||||
return Some((true, rest));
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("- [X] ") {
|
||||
return Some((true, rest));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_bullet(line: &str) -> Option<&str> {
|
||||
line.strip_prefix("- ").or_else(|| line.strip_prefix("* "))
|
||||
}
|
||||
|
||||
fn parse_numbered(line: &str) -> Option<&str> {
|
||||
let dot_index = line.find(". ")?;
|
||||
if line[..dot_index].chars().all(|ch| ch.is_ascii_digit()) {
|
||||
line.get(dot_index + 2..)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_reference_token(
|
||||
line: &str,
|
||||
prefix: &str,
|
||||
suffix: &str,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
let body = line.strip_prefix(prefix)?.strip_suffix(suffix)?;
|
||||
let mut parts = body.splitn(2, '|');
|
||||
let target_id = parts.next()?.trim();
|
||||
if target_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let label = parts.next().map(|value| value.trim().to_string());
|
||||
Some((
|
||||
target_id.to_string(),
|
||||
label.filter(|value| !value.is_empty()),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_reference_token(
|
||||
reference: Option<&crate::model::BlockReference>,
|
||||
prefix: &str,
|
||||
suffix: &str,
|
||||
) -> String {
|
||||
match reference {
|
||||
Some(reference) => match reference.label.as_deref() {
|
||||
Some(label) if !label.is_empty() => {
|
||||
format!("{prefix}{}|{label}{suffix}", reference.target_id)
|
||||
}
|
||||
_ => format!("{prefix}{}{suffix}", reference.target_id),
|
||||
},
|
||||
None => format!("{prefix}{suffix}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BlockType {
|
||||
Paragraph,
|
||||
Heading,
|
||||
BulletListItem,
|
||||
NumberedListItem,
|
||||
Todo,
|
||||
Quote,
|
||||
Divider,
|
||||
CodeBlock,
|
||||
PageReference,
|
||||
BlockReference,
|
||||
MediaPlaceholder,
|
||||
ProgressPlaceholder,
|
||||
}
|
||||
|
||||
impl BlockType {
|
||||
pub fn from_editor_label(label: &str) -> Option<Self> {
|
||||
match label {
|
||||
"paragraph" => Some(Self::Paragraph),
|
||||
"heading" => Some(Self::Heading),
|
||||
"bullet_list_item" => Some(Self::BulletListItem),
|
||||
"numbered_list_item" => Some(Self::NumberedListItem),
|
||||
"todo" => Some(Self::Todo),
|
||||
"quote" => Some(Self::Quote),
|
||||
"divider" => Some(Self::Divider),
|
||||
"code_block" => Some(Self::CodeBlock),
|
||||
"page_reference" => Some(Self::PageReference),
|
||||
"block_reference" => Some(Self::BlockReference),
|
||||
"media_placeholder" => Some(Self::MediaPlaceholder),
|
||||
"progress_placeholder" => Some(Self::ProgressPlaceholder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_editor_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "paragraph",
|
||||
Self::Heading => "heading",
|
||||
Self::BulletListItem => "bullet_list_item",
|
||||
Self::NumberedListItem => "numbered_list_item",
|
||||
Self::Todo => "todo",
|
||||
Self::Quote => "quote",
|
||||
Self::Divider => "divider",
|
||||
Self::CodeBlock => "code_block",
|
||||
Self::PageReference => "page_reference",
|
||||
Self::BlockReference => "block_reference",
|
||||
Self::MediaPlaceholder => "media_placeholder",
|
||||
Self::ProgressPlaceholder => "progress_placeholder",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReferenceKind {
|
||||
Page,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BlockReference {
|
||||
pub kind: ReferenceKind,
|
||||
pub target_id: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct BlockContent {
|
||||
pub text: String,
|
||||
pub language: Option<String>,
|
||||
pub reference: Option<BlockReference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DocumentBlock {
|
||||
pub id: String,
|
||||
pub block_type: BlockType,
|
||||
pub parent_id: Option<String>,
|
||||
pub indent: u16,
|
||||
pub collapsed: bool,
|
||||
pub heading_level: Option<u8>,
|
||||
pub checked: Option<bool>,
|
||||
pub content: BlockContent,
|
||||
}
|
||||
|
||||
impl DocumentBlock {
|
||||
pub fn new(id: impl Into<String>, block_type: BlockType) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
block_type,
|
||||
parent_id: None,
|
||||
indent: 0,
|
||||
collapsed: false,
|
||||
heading_level: None,
|
||||
checked: None,
|
||||
content: BlockContent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_parent(mut self, parent_id: impl Into<String>, indent: u16) -> Self {
|
||||
self.parent_id = Some(parent_id.into());
|
||||
self.indent = indent;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_heading_level(mut self, level: u8) -> Self {
|
||||
self.heading_level = Some(level);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_text(mut self, text: impl Into<String>) -> Self {
|
||||
self.content.text = text.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_checked(mut self, checked: bool) -> Self {
|
||||
self.checked = Some(checked);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_language(mut self, language: impl Into<String>) -> Self {
|
||||
self.content.language = Some(language.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_reference(
|
||||
mut self,
|
||||
kind: ReferenceKind,
|
||||
target_id: impl Into<String>,
|
||||
label: Option<String>,
|
||||
) -> Self {
|
||||
self.content.reference = Some(BlockReference {
|
||||
kind,
|
||||
target_id: target_id.into(),
|
||||
label,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_block_type(mut self, block_type: BlockType) -> Self {
|
||||
self.block_type = block_type;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct DocumentModel {
|
||||
blocks: Vec<DocumentBlock>,
|
||||
}
|
||||
|
||||
impl DocumentModel {
|
||||
pub fn new(blocks: Vec<DocumentBlock>) -> Self {
|
||||
Self { blocks }
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn blocks(&self) -> &[DocumentBlock] {
|
||||
&self.blocks
|
||||
}
|
||||
|
||||
pub(crate) fn blocks_mut(&mut self) -> &mut Vec<DocumentBlock> {
|
||||
&mut self.blocks
|
||||
}
|
||||
|
||||
pub fn push(&mut self, block: DocumentBlock) {
|
||||
self.blocks.push(block);
|
||||
}
|
||||
|
||||
pub fn block(&self, id: &str) -> Option<&DocumentBlock> {
|
||||
self.blocks.iter().find(|block| block.id == id)
|
||||
}
|
||||
|
||||
pub(crate) fn block_mut(&mut self, id: &str) -> Option<&mut DocumentBlock> {
|
||||
self.blocks.iter_mut().find(|block| block.id == id)
|
||||
}
|
||||
|
||||
pub fn contains_id(&self, id: &str) -> bool {
|
||||
self.block(id).is_some()
|
||||
}
|
||||
|
||||
pub fn index_of(&self, id: &str) -> Option<usize> {
|
||||
self.blocks.iter().position(|block| block.id == id)
|
||||
}
|
||||
|
||||
pub fn children_of<'a>(&'a self, parent_id: Option<&str>) -> Vec<&'a DocumentBlock> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.filter(|block| block.parent_id.as_deref() == parent_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_descendant_of(&self, block_id: &str, ancestor_id: &str) -> bool {
|
||||
let mut current_parent = self
|
||||
.block(block_id)
|
||||
.and_then(|block| block.parent_id.as_deref());
|
||||
while let Some(parent_id) = current_parent {
|
||||
if parent_id == ancestor_id {
|
||||
return true;
|
||||
}
|
||||
current_parent = self
|
||||
.block(parent_id)
|
||||
.and_then(|parent| parent.parent_id.as_deref());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn subtree_range(&self, block_id: &str) -> Option<RangeInclusive<usize>> {
|
||||
let start = self.index_of(block_id)?;
|
||||
let mut end = start;
|
||||
for next_index in (start + 1)..self.blocks.len() {
|
||||
if self.is_descendant_of(&self.blocks[next_index].id, block_id) {
|
||||
end = next_index;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(start..=end)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use crate::command::{CommandExecutor, EditorCommand};
|
||||
use crate::error::{CoreError, CoreResult};
|
||||
use crate::markdown::{import_markdown, import_plain_text};
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel};
|
||||
use crate::projection::VisibilitySnapshot;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorInputKind {
|
||||
PlainText,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorAiScenario {
|
||||
MeetingNotesToTodos,
|
||||
LongParagraphToTitle,
|
||||
PageReorder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorPipelineRequest {
|
||||
pub kind: EditorInputKind,
|
||||
pub scenario: EditorAiScenario,
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockSnapshot {
|
||||
pub block_id: String,
|
||||
pub block_type: String,
|
||||
pub text: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub indent: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandAuditRecord {
|
||||
pub command: String,
|
||||
pub target_block_id: Option<String>,
|
||||
pub before: Option<BlockSnapshot>,
|
||||
pub after: Option<BlockSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorChangeReport {
|
||||
pub created_blocks: Vec<String>,
|
||||
pub updated_blocks: Vec<String>,
|
||||
pub moved_blocks: Vec<String>,
|
||||
pub removed_blocks: Vec<String>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorPipelineResult {
|
||||
pub ok: bool,
|
||||
pub scenario: EditorAiScenario,
|
||||
pub input_kind: EditorInputKind,
|
||||
pub document: DocumentModel,
|
||||
pub audit: Vec<CommandAuditRecord>,
|
||||
pub change_report: EditorChangeReport,
|
||||
pub visible_block_ids: Vec<String>,
|
||||
pub outline_titles: Vec<String>,
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
pub fn apply_ai_pipeline(request: EditorPipelineRequest) -> CoreResult<EditorPipelineResult> {
|
||||
let mut document = match request.kind {
|
||||
EditorInputKind::PlainText => import_plain_text(&request.input)?,
|
||||
EditorInputKind::Markdown => import_markdown(&request.input)?,
|
||||
};
|
||||
if document.blocks().is_empty() {
|
||||
document.push(DocumentBlock::new("block_1", BlockType::Paragraph).with_text(""));
|
||||
}
|
||||
|
||||
let mut audit = Vec::new();
|
||||
let mut change_report = EditorChangeReport {
|
||||
created_blocks: Vec::new(),
|
||||
updated_blocks: Vec::new(),
|
||||
moved_blocks: Vec::new(),
|
||||
removed_blocks: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
};
|
||||
|
||||
match request.scenario {
|
||||
EditorAiScenario::MeetingNotesToTodos => {
|
||||
apply_meeting_notes_to_todos(&mut document, &mut audit, &mut change_report)?
|
||||
}
|
||||
EditorAiScenario::LongParagraphToTitle => {
|
||||
apply_long_paragraph_to_title(&mut document, &mut audit, &mut change_report)?
|
||||
}
|
||||
EditorAiScenario::PageReorder => {
|
||||
apply_page_reorder(&mut document, &mut audit, &mut change_report)?
|
||||
}
|
||||
}
|
||||
|
||||
let projection = VisibilitySnapshot::derive(&document);
|
||||
let markdown = crate::markdown::export_markdown(&document);
|
||||
let outline_titles = projection
|
||||
.outline
|
||||
.iter()
|
||||
.map(|entry| entry.title.clone())
|
||||
.collect();
|
||||
Ok(EditorPipelineResult {
|
||||
ok: true,
|
||||
scenario: request.scenario,
|
||||
input_kind: request.kind,
|
||||
document,
|
||||
audit,
|
||||
change_report,
|
||||
visible_block_ids: projection.visible_block_ids,
|
||||
outline_titles,
|
||||
markdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_meeting_notes_to_todos(
|
||||
document: &mut DocumentModel,
|
||||
audit: &mut Vec<CommandAuditRecord>,
|
||||
report: &mut EditorChangeReport,
|
||||
) -> CoreResult<()> {
|
||||
for index in 0..document.blocks().len() {
|
||||
let is_note = document.blocks()[index].block_type == BlockType::Paragraph
|
||||
&& document.blocks()[index].content.text.contains('待')
|
||||
&& document.blocks()[index].content.text.contains('办');
|
||||
if is_note {
|
||||
let block_id = document.blocks()[index].id.clone();
|
||||
let before = snapshot_block(document.blocks()[index].clone());
|
||||
CommandExecutor::apply(
|
||||
document,
|
||||
EditorCommand::SetBlockType {
|
||||
block_id: block_id.clone(),
|
||||
block_type: BlockType::Todo,
|
||||
},
|
||||
)?;
|
||||
let block = document
|
||||
.block(&block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.clone()))?;
|
||||
let after = snapshot_block(block.clone());
|
||||
audit.push(CommandAuditRecord {
|
||||
command: "set_block_type".into(),
|
||||
target_block_id: Some(block_id.clone()),
|
||||
before: Some(before),
|
||||
after: Some(after),
|
||||
});
|
||||
report.updated_blocks.push(block_id);
|
||||
}
|
||||
}
|
||||
report.notes.push("已将疑似会议待办段落转为 todo".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_long_paragraph_to_title(
|
||||
document: &mut DocumentModel,
|
||||
audit: &mut Vec<CommandAuditRecord>,
|
||||
report: &mut EditorChangeReport,
|
||||
) -> CoreResult<()> {
|
||||
let Some(first_id) = document.blocks().first().map(|block| block.id.clone()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let before = snapshot_block(
|
||||
document
|
||||
.block(&first_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first_id.clone()))?,
|
||||
);
|
||||
let title = document
|
||||
.block(&first_id)
|
||||
.map(|block| {
|
||||
block
|
||||
.content
|
||||
.text
|
||||
.split_whitespace()
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
CommandExecutor::apply(
|
||||
document,
|
||||
EditorCommand::SetBlockType {
|
||||
block_id: first_id.clone(),
|
||||
block_type: BlockType::Heading,
|
||||
},
|
||||
)?;
|
||||
if let Some(block) = document.block_mut(&first_id) {
|
||||
block.heading_level = Some(1);
|
||||
block.content.text = if title.is_empty() {
|
||||
"提炼标题".into()
|
||||
} else {
|
||||
title
|
||||
};
|
||||
}
|
||||
let after = snapshot_block(
|
||||
document
|
||||
.block(&first_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first_id.clone()))?,
|
||||
);
|
||||
audit.push(CommandAuditRecord {
|
||||
command: "set_block_type".into(),
|
||||
target_block_id: Some(first_id.clone()),
|
||||
before: Some(before),
|
||||
after: Some(after),
|
||||
});
|
||||
report.updated_blocks.push(first_id);
|
||||
report.notes.push("已将长段落提炼为标题".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_page_reorder(
|
||||
document: &mut DocumentModel,
|
||||
audit: &mut Vec<CommandAuditRecord>,
|
||||
report: &mut EditorChangeReport,
|
||||
) -> CoreResult<()> {
|
||||
if document.blocks().len() < 2 {
|
||||
report.notes.push("块数不足,跳过重排".into());
|
||||
return Ok(());
|
||||
}
|
||||
let first = document.blocks()[0].id.clone();
|
||||
let second = document.blocks()[1].id.clone();
|
||||
let before_first = snapshot_block(
|
||||
document
|
||||
.block(&first)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first.clone()))?,
|
||||
);
|
||||
CommandExecutor::apply(
|
||||
document,
|
||||
EditorCommand::MoveBlock {
|
||||
block_id: first.clone(),
|
||||
after_block_id: Some(second.clone()),
|
||||
},
|
||||
)?;
|
||||
let after_first = snapshot_block(
|
||||
document
|
||||
.block(&first)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first.clone()))?,
|
||||
);
|
||||
audit.push(CommandAuditRecord {
|
||||
command: "move_block".into(),
|
||||
target_block_id: Some(first.clone()),
|
||||
before: Some(before_first),
|
||||
after: Some(after_first),
|
||||
});
|
||||
report.moved_blocks.push(first);
|
||||
report.notes.push("已执行页面重排".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_block(block: DocumentBlock) -> BlockSnapshot {
|
||||
BlockSnapshot {
|
||||
block_id: block.id,
|
||||
block_type: block.block_type.as_editor_label().into(),
|
||||
text: block.content.text,
|
||||
parent_id: block.parent_id,
|
||||
indent: block.indent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineEntry {
|
||||
pub block_id: String,
|
||||
pub level: u8,
|
||||
pub title: String,
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VisibilitySnapshot {
|
||||
pub visible_block_ids: Vec<String>,
|
||||
pub outline: Vec<OutlineEntry>,
|
||||
}
|
||||
|
||||
impl VisibilitySnapshot {
|
||||
pub fn derive(document: &DocumentModel) -> Self {
|
||||
let mut hidden_ancestor_ids: Vec<String> = Vec::new();
|
||||
let mut visible_block_ids = Vec::new();
|
||||
let mut outline = Vec::new();
|
||||
|
||||
for block in document.blocks() {
|
||||
hidden_ancestor_ids.retain(|ancestor_id| is_ancestor_of(document, ancestor_id, block));
|
||||
let visible = hidden_ancestor_ids.is_empty();
|
||||
if visible {
|
||||
visible_block_ids.push(block.id.clone());
|
||||
}
|
||||
|
||||
if matches!(block.block_type, BlockType::Heading) {
|
||||
let level = block.heading_level.unwrap_or(1);
|
||||
if visible {
|
||||
outline.push(OutlineEntry {
|
||||
block_id: block.id.clone(),
|
||||
level,
|
||||
title: block.content.text.clone(),
|
||||
depth: block.indent as usize,
|
||||
});
|
||||
}
|
||||
if block.collapsed {
|
||||
hidden_ancestor_ids.push(block.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
visible_block_ids,
|
||||
outline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ancestor_of(document: &DocumentModel, ancestor_id: &str, block: &DocumentBlock) -> bool {
|
||||
document.is_descendant_of(&block.id, ancestor_id)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use mnote_editor_core::{
|
||||
BlockType, CommandExecutor, DocumentBlock, DocumentModel, EditorCommand, VisibilitySnapshot,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn command_executor_replaces_inserts_and_deletes_blocks() {
|
||||
let mut document = DocumentModel::new(vec![
|
||||
DocumentBlock::new("block_a", BlockType::Paragraph).with_text("hello"),
|
||||
DocumentBlock::new("block_b", BlockType::Paragraph).with_text("world"),
|
||||
]);
|
||||
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::ReplaceBlock {
|
||||
block_id: "block_a".into(),
|
||||
text: "hello mnote".into(),
|
||||
},
|
||||
)
|
||||
.expect("replace should succeed");
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::InsertBlockAfter {
|
||||
after_block_id: Some("block_a".into()),
|
||||
block: DocumentBlock::new("block_h", BlockType::Heading)
|
||||
.with_heading_level(2)
|
||||
.with_text("section"),
|
||||
},
|
||||
)
|
||||
.expect("insert should succeed");
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::DeleteBlock {
|
||||
block_id: "block_b".into(),
|
||||
},
|
||||
)
|
||||
.expect("delete should succeed");
|
||||
|
||||
assert_eq!(
|
||||
document
|
||||
.blocks()
|
||||
.iter()
|
||||
.map(|block| block.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["block_a", "block_h"]
|
||||
);
|
||||
assert_eq!(
|
||||
document.block("block_a").expect("block_a").content.text,
|
||||
"hello mnote"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_executor_splits_merges_moves_and_reindents_blocks() {
|
||||
let mut document = DocumentModel::new(vec![
|
||||
DocumentBlock::new("block_h", BlockType::Heading)
|
||||
.with_heading_level(1)
|
||||
.with_text("root"),
|
||||
DocumentBlock::new("block_b", BlockType::Paragraph).with_text("AlphaBeta"),
|
||||
DocumentBlock::new("block_c", BlockType::Paragraph).with_text("Tail"),
|
||||
]);
|
||||
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::SplitBlock {
|
||||
block_id: "block_b".into(),
|
||||
offset: 5,
|
||||
new_block_id: "block_d".into(),
|
||||
},
|
||||
)
|
||||
.expect("split should succeed");
|
||||
assert_eq!(
|
||||
document.block("block_b").expect("block_b").content.text,
|
||||
"Alpha"
|
||||
);
|
||||
assert_eq!(
|
||||
document.block("block_d").expect("block_d").content.text,
|
||||
"Beta"
|
||||
);
|
||||
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::MergeWithPrevious {
|
||||
block_id: "block_d".into(),
|
||||
},
|
||||
)
|
||||
.expect("merge should succeed");
|
||||
assert_eq!(
|
||||
document.block("block_b").expect("block_b").content.text,
|
||||
"AlphaBeta"
|
||||
);
|
||||
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::MoveBlock {
|
||||
block_id: "block_c".into(),
|
||||
after_block_id: Some("block_h".into()),
|
||||
},
|
||||
)
|
||||
.expect("move should succeed");
|
||||
assert_eq!(
|
||||
document
|
||||
.blocks()
|
||||
.iter()
|
||||
.map(|block| block.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["block_h", "block_c", "block_b"]
|
||||
);
|
||||
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::IndentBlock {
|
||||
block_id: "block_b".into(),
|
||||
},
|
||||
)
|
||||
.expect("indent should succeed");
|
||||
let indented = document.block("block_b").expect("block_b after indent");
|
||||
assert_eq!(indented.parent_id.as_deref(), Some("block_c"));
|
||||
assert_eq!(indented.indent, 1);
|
||||
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::OutdentBlock {
|
||||
block_id: "block_b".into(),
|
||||
},
|
||||
)
|
||||
.expect("outdent should succeed");
|
||||
let outdented = document.block("block_b").expect("block_b after outdent");
|
||||
assert_eq!(outdented.parent_id, None);
|
||||
assert_eq!(outdented.indent, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_executor_toggles_heading_collapse_visibility() {
|
||||
let mut document = DocumentModel::new(vec![
|
||||
DocumentBlock::new("heading_root", BlockType::Heading)
|
||||
.with_heading_level(1)
|
||||
.with_text("root"),
|
||||
DocumentBlock::new("child_para", BlockType::Paragraph)
|
||||
.with_parent("heading_root", 1)
|
||||
.with_text("hidden after collapse"),
|
||||
DocumentBlock::new("tail", BlockType::Paragraph).with_text("tail"),
|
||||
]);
|
||||
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::ToggleHeadingCollapse {
|
||||
block_id: "heading_root".into(),
|
||||
},
|
||||
)
|
||||
.expect("toggle collapse should succeed");
|
||||
|
||||
assert!(document.block("heading_root").expect("heading").collapsed);
|
||||
let projection = VisibilitySnapshot::derive(&document);
|
||||
assert_eq!(
|
||||
projection.visible_block_ids,
|
||||
vec!["heading_root".to_string(), "tail".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_executor_changes_block_type() {
|
||||
let mut document =
|
||||
DocumentModel::new(vec![DocumentBlock::new("block_a", BlockType::Paragraph)]);
|
||||
CommandExecutor::apply(
|
||||
&mut document,
|
||||
EditorCommand::SetBlockType {
|
||||
block_id: "block_a".into(),
|
||||
block_type: BlockType::Todo,
|
||||
},
|
||||
)
|
||||
.expect("set block type should succeed");
|
||||
|
||||
assert_eq!(
|
||||
document.block("block_a").expect("block_a").block_type,
|
||||
BlockType::Todo
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, VisibilitySnapshot};
|
||||
|
||||
#[test]
|
||||
fn document_model_preserves_parent_structure_and_outline_visibility() {
|
||||
let document = DocumentModel::new(vec![
|
||||
DocumentBlock::new("heading_root", BlockType::Heading)
|
||||
.with_heading_level(1)
|
||||
.with_text("根标题")
|
||||
.with_collapsed(true),
|
||||
DocumentBlock::new("paragraph_hidden", BlockType::Paragraph)
|
||||
.with_parent("heading_root", 1)
|
||||
.with_text("折叠后应隐藏"),
|
||||
DocumentBlock::new("heading_visible", BlockType::Heading)
|
||||
.with_heading_level(2)
|
||||
.with_text("次级标题"),
|
||||
DocumentBlock::new("todo_visible", BlockType::Todo)
|
||||
.with_parent("heading_visible", 1)
|
||||
.with_text("仍然可见"),
|
||||
]);
|
||||
|
||||
assert_eq!(document.blocks().len(), 4);
|
||||
assert_eq!(
|
||||
document
|
||||
.children_of(Some("heading_root"))
|
||||
.into_iter()
|
||||
.map(|block| block.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["paragraph_hidden"]
|
||||
);
|
||||
|
||||
let projection = VisibilitySnapshot::derive(&document);
|
||||
|
||||
assert_eq!(
|
||||
projection.visible_block_ids,
|
||||
vec![
|
||||
"heading_root".to_string(),
|
||||
"heading_visible".to_string(),
|
||||
"todo_visible".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(projection.outline.len(), 2);
|
||||
assert_eq!(projection.outline[0].block_id, "heading_root");
|
||||
assert_eq!(projection.outline[0].title, "根标题");
|
||||
assert_eq!(projection.outline[1].block_id, "heading_visible");
|
||||
assert_eq!(projection.outline[1].level, 2);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
|
||||
|
||||
#[test]
|
||||
fn history_undo_redo_restores_command_sequence() {
|
||||
let mut session = EditorSession::new(DocumentModel::new(vec![DocumentBlock::new(
|
||||
"block_a",
|
||||
BlockType::Paragraph,
|
||||
)
|
||||
.with_text("hello")]));
|
||||
|
||||
session
|
||||
.apply_command(EditorCommand::ReplaceBlock {
|
||||
block_id: "block_a".into(),
|
||||
text: "hello world".into(),
|
||||
})
|
||||
.expect("replace should succeed");
|
||||
session
|
||||
.apply_command(EditorCommand::InsertBlockAfter {
|
||||
after_block_id: Some("block_a".into()),
|
||||
block: DocumentBlock::new("block_b", BlockType::Paragraph).with_text("tail"),
|
||||
})
|
||||
.expect("insert should succeed");
|
||||
|
||||
assert_eq!(session.document().blocks().len(), 2);
|
||||
assert!(session.undo());
|
||||
assert_eq!(session.document().blocks().len(), 1);
|
||||
assert_eq!(
|
||||
session
|
||||
.document()
|
||||
.block("block_a")
|
||||
.expect("block_a after undo")
|
||||
.content
|
||||
.text,
|
||||
"hello world"
|
||||
);
|
||||
assert!(session.undo());
|
||||
assert_eq!(
|
||||
session
|
||||
.document()
|
||||
.block("block_a")
|
||||
.expect("block_a after second undo")
|
||||
.content
|
||||
.text,
|
||||
"hello"
|
||||
);
|
||||
assert!(session.redo());
|
||||
assert!(session.redo());
|
||||
assert_eq!(session.document().blocks().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_clears_redo_after_new_command() {
|
||||
let mut session = EditorSession::new(DocumentModel::new(vec![DocumentBlock::new(
|
||||
"block_a",
|
||||
BlockType::Paragraph,
|
||||
)
|
||||
.with_text("hello")]));
|
||||
|
||||
session
|
||||
.apply_command(EditorCommand::ReplaceBlock {
|
||||
block_id: "block_a".into(),
|
||||
text: "hello world".into(),
|
||||
})
|
||||
.expect("replace should succeed");
|
||||
assert!(session.undo());
|
||||
session
|
||||
.apply_command(EditorCommand::InsertBlockAfter {
|
||||
after_block_id: Some("block_a".into()),
|
||||
block: DocumentBlock::new("block_b", BlockType::Paragraph).with_text("fresh"),
|
||||
})
|
||||
.expect("insert should succeed");
|
||||
|
||||
assert!(!session.redo());
|
||||
assert_eq!(session.document().blocks().len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use mnote_editor_core::{
|
||||
export_markdown, import_markdown, import_plain_text, BlockType, ReferenceKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn markdown_import_export_round_trip_preserves_block_kinds() {
|
||||
let source = r#"# 根标题
|
||||
- 列表项
|
||||
- [x] 已完成
|
||||
> 引用
|
||||
---
|
||||
```rust
|
||||
fn main() {}
|
||||
```
|
||||
[[page_1|页面一]]
|
||||
((block_1|块一))
|
||||
普通段落
|
||||
"#;
|
||||
|
||||
let document = import_markdown(source).expect("markdown import should succeed");
|
||||
assert_eq!(
|
||||
document
|
||||
.blocks()
|
||||
.iter()
|
||||
.map(|block| block.block_type.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
BlockType::Heading,
|
||||
BlockType::BulletListItem,
|
||||
BlockType::Todo,
|
||||
BlockType::Quote,
|
||||
BlockType::Divider,
|
||||
BlockType::CodeBlock,
|
||||
BlockType::PageReference,
|
||||
BlockType::BlockReference,
|
||||
BlockType::Paragraph,
|
||||
]
|
||||
);
|
||||
|
||||
let exported = export_markdown(&document);
|
||||
let reparsed = import_markdown(&exported).expect("markdown re-import should succeed");
|
||||
assert_eq!(
|
||||
reparsed
|
||||
.blocks()
|
||||
.iter()
|
||||
.map(|block| block.block_type.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
document
|
||||
.blocks()
|
||||
.iter()
|
||||
.map(|block| block.block_type.clone())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_export_renders_reference_tokens_and_code_fence() {
|
||||
let source = r#"[[page_42|设计文档]]
|
||||
((block_99|引用块))
|
||||
```ts
|
||||
console.log("ok");
|
||||
```
|
||||
"#;
|
||||
let document = import_markdown(source).expect("markdown import should succeed");
|
||||
|
||||
let page_ref = document.blocks()[0]
|
||||
.content
|
||||
.reference
|
||||
.as_ref()
|
||||
.expect("page reference");
|
||||
assert_eq!(page_ref.kind, ReferenceKind::Page);
|
||||
let block_ref = document.blocks()[1]
|
||||
.content
|
||||
.reference
|
||||
.as_ref()
|
||||
.expect("block reference");
|
||||
assert_eq!(block_ref.kind, ReferenceKind::Block);
|
||||
|
||||
let exported = export_markdown(&document);
|
||||
assert!(exported.contains("[[page_42|设计文档]]"));
|
||||
assert!(exported.contains("((block_99|引用块))"));
|
||||
assert!(exported.contains("```ts"));
|
||||
assert!(exported.contains("console.log(\"ok\");"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_import_splits_paragraphs_and_normalizes_line_breaks() {
|
||||
let document =
|
||||
import_plain_text("第一段\n继续\n\n第二段").expect("plain text import should succeed");
|
||||
assert_eq!(document.blocks().len(), 2);
|
||||
assert_eq!(document.blocks()[0].content.text, "第一段 继续");
|
||||
assert_eq!(document.blocks()[1].content.text, "第二段");
|
||||
assert_eq!(document.blocks()[0].id, "imported_1");
|
||||
assert_eq!(document.blocks()[1].id, "imported_2");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use mnote_editor_core::{
|
||||
apply_ai_pipeline, EditorAiScenario, EditorInputKind, EditorPipelineRequest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn ai_pipeline_turns_meeting_notes_into_todos() {
|
||||
let result = apply_ai_pipeline(EditorPipelineRequest {
|
||||
kind: EditorInputKind::PlainText,
|
||||
scenario: EditorAiScenario::MeetingNotesToTodos,
|
||||
input: "待办:整理会议纪要\n普通段落".into(),
|
||||
})
|
||||
.expect("pipeline should succeed");
|
||||
|
||||
assert_eq!(
|
||||
result.document.blocks()[0].block_type.as_editor_label(),
|
||||
"todo"
|
||||
);
|
||||
assert_eq!(result.audit.len(), 1);
|
||||
assert!(result
|
||||
.change_report
|
||||
.updated_blocks
|
||||
.contains(&"imported_1".to_string()));
|
||||
assert!(result.markdown.contains("待办:整理会议纪要"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_pipeline_refines_long_paragraph_to_title() {
|
||||
let result = apply_ai_pipeline(EditorPipelineRequest {
|
||||
kind: EditorInputKind::PlainText,
|
||||
scenario: EditorAiScenario::LongParagraphToTitle,
|
||||
input: "这是一个很长的段落 用于提炼 标题".into(),
|
||||
})
|
||||
.expect("pipeline should succeed");
|
||||
|
||||
assert_eq!(
|
||||
result.document.blocks()[0].block_type.as_editor_label(),
|
||||
"heading"
|
||||
);
|
||||
assert_eq!(result.document.blocks()[0].heading_level, Some(1));
|
||||
assert_eq!(result.audit.len(), 1);
|
||||
assert!(result
|
||||
.change_report
|
||||
.updated_blocks
|
||||
.contains(&"imported_1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_pipeline_reorders_first_block_after_second() {
|
||||
let result = apply_ai_pipeline(EditorPipelineRequest {
|
||||
kind: EditorInputKind::PlainText,
|
||||
scenario: EditorAiScenario::PageReorder,
|
||||
input: "第一页\n\n第二页".into(),
|
||||
})
|
||||
.expect("pipeline should succeed");
|
||||
|
||||
assert_eq!(result.document.blocks()[0].content.text, "第二页");
|
||||
assert_eq!(result.document.blocks()[1].content.text, "第一页");
|
||||
assert_eq!(result.audit.len(), 1);
|
||||
assert_eq!(
|
||||
result.change_report.moved_blocks,
|
||||
vec!["imported_1".to_string()]
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,11 @@ bridge-runtime = { path = "../bridge-runtime" }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
futures-util = "0.3"
|
||||
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentContentQuery {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentMetaQuery {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentSaveRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
pub content: Value,
|
||||
pub snapshot_captured_at: Option<String>,
|
||||
pub block_count: Option<u64>,
|
||||
}
|
||||
|
||||
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
|
||||
|
||||
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"result": result,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
let trimmed = value.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../..")
|
||||
.join(".env.all");
|
||||
let content = fs::read_to_string(root).ok()?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.starts_with('#') || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if k.trim() != key {
|
||||
continue;
|
||||
}
|
||||
let trimmed = v.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn next_documents_base_url() -> String {
|
||||
read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV)
|
||||
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn should_proxy_via_next(context: &RequestContext) -> bool {
|
||||
context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn build_next_proxy_headers(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
) -> reqwest::header::HeaderMap {
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
||||
let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(header_value) = HeaderValue::from_str(value) else {
|
||||
return;
|
||||
};
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(cookie) = context.auth.cookie_header.as_deref() {
|
||||
insert(&mut headers, "cookie", cookie);
|
||||
}
|
||||
if let Some(authorization) = context.auth.authorization.as_deref() {
|
||||
insert(&mut headers, "authorization", authorization);
|
||||
}
|
||||
insert(&mut headers, "x-request-id", &context.trace.request_id);
|
||||
insert(&mut headers, "x-trace-id", &context.trace.trace_id);
|
||||
insert(
|
||||
&mut headers,
|
||||
"x-mnote-source-channel",
|
||||
&context.source.channel,
|
||||
);
|
||||
insert(
|
||||
&mut headers,
|
||||
"x-mnote-source-client",
|
||||
&context.source.client,
|
||||
);
|
||||
insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id);
|
||||
insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type);
|
||||
if let Some(session_id) = context.auth.session_id.as_deref() {
|
||||
insert(&mut headers, "x-mnote-session-id", session_id);
|
||||
}
|
||||
if let Some(workspace_id) = effective_workspace_id {
|
||||
insert(&mut headers, "x-mnote-workspace-id", workspace_id);
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
async fn send_next_documents_request(
|
||||
context: &RequestContext,
|
||||
request: reqwest::RequestBuilder,
|
||||
phase: &'static str,
|
||||
) -> Result<Value, WebError> {
|
||||
let response = request.send().await.map_err(|error| {
|
||||
let base = if error.is_timeout() {
|
||||
WebError::gateway_timeout_code(
|
||||
"next_proxy_timeout",
|
||||
format!("Next compat 请求超时: {error}"),
|
||||
)
|
||||
} else {
|
||||
WebError::service_unavailable_code(
|
||||
"next_proxy_unavailable",
|
||||
format!("Next compat 请求失败: {error}"),
|
||||
)
|
||||
};
|
||||
base.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"next_proxy_bad_response",
|
||||
format!("Next compat 响应读取失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||
})?;
|
||||
|
||||
let payload = serde_json::from_str::<Value>(&text).map_err(|_| {
|
||||
let snippet: String = text.chars().take(180).collect();
|
||||
WebError::bad_gateway_code(
|
||||
"next_proxy_bad_response",
|
||||
format!("Next compat 返回了非 JSON 内容: {snippet}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
let message = payload
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| payload.get("message").and_then(Value::as_str))
|
||||
.unwrap_or("Next compat 文档接口请求失败");
|
||||
let web_error = match status {
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
|
||||
WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message)
|
||||
}
|
||||
reqwest::StatusCode::NOT_FOUND => {
|
||||
WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message)
|
||||
}
|
||||
_ => WebError::bad_gateway_code("next_proxy_error", message),
|
||||
};
|
||||
return Err(web_error
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string()));
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn proxy_next_documents_meta(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let base_url = next_documents_base_url();
|
||||
let mut url =
|
||||
reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| {
|
||||
WebError::internal(format!("Next compat meta URL 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_meta_url")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
url.query_pairs_mut().append_pair("documentId", document_id);
|
||||
if let Some(workspace_id) = effective_workspace_id {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("workspaceId", workspace_id);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_meta_client")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let payload = send_next_documents_request(
|
||||
context,
|
||||
client
|
||||
.get(url)
|
||||
.headers(build_next_proxy_headers(context, effective_workspace_id)),
|
||||
"next_proxy_meta",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(payload.get("doc").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn proxy_next_documents_content(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let base_url = next_documents_base_url();
|
||||
let mut url =
|
||||
reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| {
|
||||
WebError::internal(format!("Next compat content URL 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_content_url")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
url.query_pairs_mut().append_pair("documentId", document_id);
|
||||
if let Some(workspace_id) = effective_workspace_id {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("workspaceId", workspace_id);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_content_client")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let payload = send_next_documents_request(
|
||||
context,
|
||||
client
|
||||
.get(url)
|
||||
.headers(build_next_proxy_headers(context, effective_workspace_id)),
|
||||
"next_proxy_content",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json!({
|
||||
"content": payload.get("content").cloned().unwrap_or(Value::Null),
|
||||
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn proxy_next_documents_save(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
body: &DocumentSaveRequest,
|
||||
) -> Result<Value, WebError> {
|
||||
let base_url = next_documents_base_url();
|
||||
let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| {
|
||||
WebError::internal(format!("Next compat save URL 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_save_url")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_save_client")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let payload = send_next_documents_request(
|
||||
context,
|
||||
client
|
||||
.post(url)
|
||||
.headers(build_next_proxy_headers(context, effective_workspace_id))
|
||||
.json(&json!({
|
||||
"documentId": body.document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"content": body.content,
|
||||
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||
"blockCount": body.block_count,
|
||||
})),
|
||||
"next_proxy_save",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json!({
|
||||
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": payload
|
||||
.get("conflictDetectionKey")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
"ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn meta(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<DocumentMetaQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let document_id = query.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(&context) {
|
||||
let result =
|
||||
proxy_next_documents_meta(&context, effective_workspace_id.as_deref(), document_id)
|
||||
.await?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let result = fetch_documents_meta_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
document_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn content(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<DocumentContentQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let document_id = query.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(&context) {
|
||||
let result =
|
||||
proxy_next_documents_content(&context, effective_workspace_id.as_deref(), document_id)
|
||||
.await?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let result = execute_runtime_query_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "documents.content.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentSaveRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(&context) {
|
||||
let result =
|
||||
proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "documents.save".into(),
|
||||
command_id: format!("document_save_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"content": body.content,
|
||||
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||
"blockCount": body.block_count,
|
||||
}),
|
||||
reason: Some("mnote-web human editor save".into()),
|
||||
refs: vec!["mnote-web-editor-runtime".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"updated_at": "2026-04-18T09:30:00Z",
|
||||
"can_edit": true,
|
||||
"disable_download": false,
|
||||
"disable_copy": false,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_heading_numbers": true,
|
||||
"show_toc": true,
|
||||
"show_structure": true,
|
||||
"protect_editing": false,
|
||||
"show_word_count": true,
|
||||
"collapse_backlinks": false,
|
||||
"page_font": "default",
|
||||
"layout_density": "normal",
|
||||
"hide_child_pages": false,
|
||||
"show_block_ref_count": true,
|
||||
"embed_default_block_id": "heading_1",
|
||||
"word_count": 42,
|
||||
"character_count": 128,
|
||||
"block_count": 3,
|
||||
"todo_total": 1,
|
||||
"todo_done": 0
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "章节一" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateContent": {
|
||||
"ok": true,
|
||||
"updated_at": "2026-04-18T09:45:00Z",
|
||||
"revision": 8,
|
||||
"conflict_detection_key": "doc_1:8"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_meta_route_returns_document_metadata() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/documents/meta?documentId=doc_1&workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["id"], "doc_1");
|
||||
assert_eq!(payload["result"]["workspace_id"], "ws_demo");
|
||||
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||
assert_eq!(payload["result"]["show_structure"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_content_route_returns_page_subtree() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/documents/content?documentId=doc_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["revision"], 7);
|
||||
assert_eq!(payload["result"]["pageSubtree"]["rootNodeId"], "doc_1");
|
||||
assert_eq!(
|
||||
payload["result"]["pageSubtree"]["outline"][0]["title"],
|
||||
"章节一"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_save_route_executes_documents_save_command() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/save")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7",
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "章节一(已编辑)" }]
|
||||
}
|
||||
],
|
||||
"blockCount": 1
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["revision"], 8);
|
||||
assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
mod bridge;
|
||||
mod command_support;
|
||||
mod compat;
|
||||
mod documents;
|
||||
mod editor;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
@@ -22,6 +24,15 @@ pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/tree", get(tree::tree_shell))
|
||||
.route("/document-debug", get(editor::document_editor_shell))
|
||||
.route("/document", get(editor::document_editor_shell))
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/save", post(documents::save))
|
||||
.route(
|
||||
"/api/documents/runtime/transform",
|
||||
post(editor::transform_runtime_snapshot),
|
||||
)
|
||||
.route(
|
||||
"/api/tree/projections/sidebar",
|
||||
get(kernel::project_tree_sidebar),
|
||||
|
||||
@@ -7,7 +7,9 @@ use bridge_runtime::{
|
||||
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
|
||||
RuntimeSourceWire,
|
||||
};
|
||||
use core_protocol::{GetPageMeta, QueryEnvelope};
|
||||
use serde_json::Value;
|
||||
use storage_convex_bridge::{build_query_request, BridgeContext};
|
||||
|
||||
pub fn resolve_effective_workspace_id(
|
||||
context: &RequestContext,
|
||||
@@ -77,6 +79,31 @@ pub fn runtime_context(
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_context(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
) -> BridgeContext {
|
||||
BridgeContext {
|
||||
deployment_id: context.workspace.deployment_id.clone(),
|
||||
project_id: context.workspace.project_id.clone(),
|
||||
request_id: context.trace.request_id.clone(),
|
||||
trace_id: context.trace.trace_id.clone(),
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
workspace_id: effective_workspace_id
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| context.workspace.workspace_id.clone()),
|
||||
tenant_id: context.workspace.tenant_id.clone(),
|
||||
auth_token: context.auth.authorization.clone(),
|
||||
source_channel: context.source.channel.clone(),
|
||||
source_client: context.source.client.clone(),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
validate_only: false,
|
||||
dry_run: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_query_plan(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
@@ -96,6 +123,34 @@ pub fn build_runtime_query_plan(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub fn build_documents_meta_query_plan(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<RuntimeQueryExecutionPlan, WebError> {
|
||||
let query = QueryEnvelope {
|
||||
name: "documents.meta.get".into(),
|
||||
payload: GetPageMeta {
|
||||
page_id: document_id.to_string(),
|
||||
workspace_id: effective_workspace_id.map(ToOwned::to_owned),
|
||||
},
|
||||
};
|
||||
let request = build_query_request(&storage_context(context, effective_workspace_id), &query)
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))?;
|
||||
Ok(RuntimeQueryExecutionPlan {
|
||||
query_name: query.name,
|
||||
function_name: request.function_name,
|
||||
workspace_id: request.workspace_id,
|
||||
request_id: request.request_id,
|
||||
trace_id: request.trace_id,
|
||||
actor_id: request.actor_id,
|
||||
payload_json: request.payload_json,
|
||||
args_json: serde_json::json!({
|
||||
"id": document_id,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_query_data_via_convex(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -106,6 +161,16 @@ pub async fn fetch_query_data_via_convex(
|
||||
execute_convex_query_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub async fn fetch_documents_meta_via_convex(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?;
|
||||
execute_convex_query_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub fn execute_runtime_query_against_data(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
|
||||
@@ -261,7 +261,8 @@ mod tests {
|
||||
page_id: "page_2".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
revision: Some(7),
|
||||
content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}".into(),
|
||||
content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}"
|
||||
.into(),
|
||||
conflict_detection_key: Some("rev:7".into()),
|
||||
},
|
||||
reason: Some("嵌入页面".into()),
|
||||
@@ -274,7 +275,9 @@ mod tests {
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "documents:updateContent");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"documents.embed\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"documents.embed\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -769,9 +772,7 @@ mod tests {
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "search:recent");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"search.recent\""));
|
||||
assert!(request.payload_json.contains("\"name\":\"search.recent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,6 +28,34 @@ impl BridgeError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Unauthorized,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Conflict,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::NotFound,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rejected(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Rejected,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type BridgeResult<T> = Result<T, BridgeError>;
|
||||
|
||||
Reference in New Issue
Block a user