use std::path::PathBuf;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::link_parser::LinkFormat;
use crate::search::SearchResults;
use crate::types::FileInfo;
use crate::validate::{
FixResult, ValidationError, ValidationResult, ValidationResultWithMeta, ValidationWarning,
};
use crate::workspace::{TreeNode, WorkspaceConfig};
use crate::yaml_value::YamlValue;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(tag = "type", content = "params")]
pub enum Command {
GetEntry {
path: String,
},
SaveEntry {
path: String,
content: String,
#[serde(default)]
root_index_path: Option<String>,
#[serde(default)]
detect_h1_title: bool,
},
CreateEntry {
path: String,
#[serde(default)]
options: CreateEntryOptions,
},
DeleteEntry {
path: String,
#[serde(default)]
hard_delete: bool,
},
MoveEntry {
from: String,
to: String,
},
SyncMoveMetadata {
old_path: String,
new_path: String,
},
SyncCreateMetadata {
path: String,
},
SyncDeleteMetadata {
path: String,
},
RenameEntry {
path: String,
new_filename: String,
},
DuplicateEntry {
path: String,
},
ConvertToIndex {
path: String,
},
ConvertToLeaf {
path: String,
},
CreateChildEntry {
parent_path: String,
},
AttachEntryToParent {
entry_path: String,
parent_path: String,
},
AddLink {
source_path: String,
target_path: String,
#[serde(default)]
content: Option<String>,
},
RemoveLink {
source_path: String,
target_path: String,
#[serde(default)]
content: Option<String>,
},
FindRootIndex {
directory: String,
},
GetAvailableAudiences {
path: String,
},
GetEffectiveAudience {
path: String,
},
GetWorkspaceTree {
path: Option<String>,
depth: Option<u32>,
audience: Option<Vec<String>>,
},
GetWorkspaceFileSet {
path: String,
},
PrepareMultiDelete {
paths: Vec<String>,
#[serde(default)]
tree_path: Option<String>,
},
CheckDeleteIncludesDescendants {
paths: Vec<String>,
#[serde(default)]
tree_path: Option<String>,
},
GetFilesystemTree {
path: Option<String>,
#[serde(default)]
show_hidden: bool,
depth: Option<u32>,
},
CreateWorkspace {
path: Option<String>,
name: Option<String>,
},
GetFrontmatter {
path: String,
},
SetFrontmatterProperty {
path: String,
key: String,
value: YamlValue,
#[serde(default)]
root_index_path: Option<String>,
},
RemoveFrontmatterProperty {
path: String,
key: String,
},
ReorderFrontmatterKeys {
path: String,
keys: Vec<String>,
},
MoveFrontmatterSectionToFile {
source_path: String,
section_key: String,
target_path: String,
#[serde(default)]
create_if_missing: bool,
},
SearchWorkspace {
pattern: String,
#[serde(default)]
options: SearchOptions,
},
ValidateWorkspace {
path: Option<String>,
},
ValidateFile {
path: String,
},
FixAll {
validation_result: ValidationResult,
},
FixValidationWarning {
warning: ValidationWarning,
},
FixValidationError {
error: ValidationError,
},
GetAvailableParentIndexes {
file_path: String,
workspace_root: String,
},
GetAttachments {
path: String,
},
RegisterAttachment {
entry_path: String,
filename: String,
},
DeleteAttachment {
entry_path: String,
attachment_path: String,
},
GetAttachmentData {
entry_path: String,
attachment_path: String,
},
ResolveAttachmentPath {
entry_path: String,
attachment_path: String,
},
MoveAttachment {
source_entry_path: String,
target_entry_path: String,
attachment_path: String,
new_filename: Option<String>,
},
GetAncestorAttachments {
path: String,
},
FileExists {
path: String,
},
ReadFile {
path: String,
},
GetFileInfo {
path: String,
},
WriteFile {
path: String,
content: String,
},
DeleteFile {
path: String,
},
ClearDirectory {
path: String,
},
WriteFileWithMetadata {
path: String,
metadata: serde_json::Value,
body: String,
},
UpdateFileMetadata {
path: String,
metadata: serde_json::Value,
body: Option<String>,
},
GetStorageUsage,
GetLinkFormat {
root_index_path: String,
},
SetLinkFormat {
root_index_path: String,
format: String,
},
GetWorkspaceConfig {
root_index_path: String,
},
GenerateFilename {
title: String,
root_index_path: Option<String>,
},
SetWorkspaceConfig {
root_index_path: String,
field: String,
value: String,
},
ConvertLinks {
root_index_path: String,
format: String,
path: Option<String>,
#[serde(default)]
dry_run: bool,
},
LinkParser {
operation: LinkParserOperation,
},
ValidateWorkspaceName {
name: String,
existing_local_names: Vec<String>,
#[serde(default)]
existing_server_names: Option<Vec<String>>,
},
ValidatePublishingSlug {
slug: String,
},
NormalizeServerUrl {
url: String,
},
PluginCommand {
plugin: String,
command: String,
params: JsonValue,
},
GetPluginManifests,
GetPluginConfig {
plugin: String,
},
SetPluginConfig {
plugin: String,
config: JsonValue,
},
RemoveWorkspacePluginData {
root_index_path: String,
plugin: String,
},
}
impl Command {
pub fn normalize_paths(&mut self, normalizer: impl Fn(&str) -> String) {
match self {
Command::GetEntry { path }
| Command::DeleteEntry { path, .. }
| Command::SyncCreateMetadata { path }
| Command::SyncDeleteMetadata { path }
| Command::RenameEntry { path, .. }
| Command::DuplicateEntry { path }
| Command::ConvertToIndex { path }
| Command::ConvertToLeaf { path }
| Command::GetFrontmatter { path }
| Command::RemoveFrontmatterProperty { path, .. }
| Command::ReorderFrontmatterKeys { path, .. }
| Command::ValidateFile { path }
| Command::GetAttachments { path }
| Command::GetAncestorAttachments { path }
| Command::FileExists { path }
| Command::ReadFile { path }
| Command::GetFileInfo { path }
| Command::WriteFile { path, .. }
| Command::DeleteFile { path }
| Command::ClearDirectory { path }
| Command::WriteFileWithMetadata { path, .. }
| Command::UpdateFileMetadata { path, .. }
| Command::GetAvailableAudiences { path }
| Command::GetEffectiveAudience { path }
| Command::GetWorkspaceFileSet { path } => {
*path = normalizer(path);
}
Command::RemoveWorkspacePluginData {
root_index_path, ..
} => {
*root_index_path = normalizer(root_index_path);
}
Command::GetWorkspaceTree { path, .. } | Command::ValidateWorkspace { path } => {
if let Some(p) = path {
*p = normalizer(p);
}
}
Command::PrepareMultiDelete { paths, tree_path }
| Command::CheckDeleteIncludesDescendants { paths, tree_path } => {
for p in paths.iter_mut() {
*p = normalizer(p);
}
if let Some(tp) = tree_path {
*tp = normalizer(tp);
}
}
Command::GetFilesystemTree { .. } | Command::CreateWorkspace { .. } => {}
Command::SaveEntry {
path,
root_index_path,
..
} => {
*path = normalizer(path);
if let Some(rip) = root_index_path {
*rip = normalizer(rip);
}
}
Command::CreateEntry { path, options } => {
*path = normalizer(path);
if let Some(rip) = &mut options.root_index_path {
*rip = normalizer(rip);
}
}
Command::SetFrontmatterProperty {
path,
root_index_path,
..
} => {
*path = normalizer(path);
if let Some(rip) = root_index_path {
*rip = normalizer(rip);
}
}
Command::MoveEntry { from, to } => {
*from = normalizer(from);
*to = normalizer(to);
}
Command::SyncMoveMetadata { old_path, new_path } => {
*old_path = normalizer(old_path);
*new_path = normalizer(new_path);
}
Command::MoveFrontmatterSectionToFile {
source_path,
target_path,
..
} => {
*source_path = normalizer(source_path);
*target_path = normalizer(target_path);
}
Command::CreateChildEntry { parent_path } => {
*parent_path = normalizer(parent_path);
}
Command::AttachEntryToParent {
entry_path,
parent_path,
} => {
*entry_path = normalizer(entry_path);
*parent_path = normalizer(parent_path);
}
Command::AddLink {
source_path,
target_path,
..
}
| Command::RemoveLink {
source_path,
target_path,
..
} => {
*source_path = normalizer(source_path);
*target_path = normalizer(target_path);
}
Command::FindRootIndex { .. } => {}
Command::SearchWorkspace { options, .. } => {
if let Some(wp) = &mut options.workspace_path {
*wp = normalizer(wp);
}
}
Command::GetAvailableParentIndexes {
file_path,
workspace_root,
} => {
*file_path = normalizer(file_path);
*workspace_root = normalizer(workspace_root);
}
Command::FixAll { .. }
| Command::FixValidationWarning { .. }
| Command::FixValidationError { .. } => {}
Command::RegisterAttachment { entry_path, .. }
| Command::DeleteAttachment { entry_path, .. }
| Command::GetAttachmentData { entry_path, .. }
| Command::ResolveAttachmentPath { entry_path, .. } => {
*entry_path = normalizer(entry_path);
}
Command::MoveAttachment {
source_entry_path,
target_entry_path,
..
} => {
*source_entry_path = normalizer(source_entry_path);
*target_entry_path = normalizer(target_entry_path);
}
Command::GetStorageUsage => {}
Command::GetLinkFormat { root_index_path }
| Command::SetLinkFormat {
root_index_path, ..
}
| Command::GetWorkspaceConfig { root_index_path }
| Command::SetWorkspaceConfig {
root_index_path, ..
} => {
*root_index_path = normalizer(root_index_path);
}
Command::GenerateFilename {
root_index_path, ..
} => {
if let Some(rip) = root_index_path {
*rip = normalizer(rip);
}
}
Command::ConvertLinks {
root_index_path,
path,
..
} => {
*root_index_path = normalizer(root_index_path);
if let Some(p) = path {
*p = normalizer(p);
}
}
Command::LinkParser { operation } => match operation {
LinkParserOperation::Parse { .. } => {}
LinkParserOperation::ToCanonical {
current_file_path, ..
} => {
*current_file_path = normalizer(current_file_path);
}
LinkParserOperation::Format {
canonical_path,
from_canonical_path,
..
} => {
*canonical_path = normalizer(canonical_path);
*from_canonical_path = normalizer(from_canonical_path);
}
LinkParserOperation::Convert {
current_file_path, ..
} => {
*current_file_path = normalizer(current_file_path);
}
},
Command::ValidateWorkspaceName { .. }
| Command::ValidatePublishingSlug { .. }
| Command::NormalizeServerUrl { .. }
| Command::PluginCommand { .. }
| Command::GetPluginManifests
| Command::GetPluginConfig { .. }
| Command::SetPluginConfig { .. } => {}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct CreateChildResult {
pub child_path: String,
pub parent_path: String,
pub parent_converted: bool,
#[cfg_attr(feature = "typescript", ts(optional))]
#[serde(skip_serializing_if = "Option::is_none")]
pub original_parent_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(tag = "type", content = "data")]
pub enum Response {
Ok,
String(String),
Bool(bool),
Entry(EntryData),
FileInfo(FileInfo),
Tree(TreeNode),
Frontmatter(IndexMap<String, YamlValue>),
SearchResults(SearchResults),
ValidationResult(ValidationResultWithMeta),
FixResult(FixResult),
FixSummary(FixSummary),
Strings(Vec<String>),
Bytes(Vec<u8>),
StorageInfo(StorageInfo),
AncestorAttachments(AncestorAttachmentsResult),
EffectiveAudience(EffectiveAudienceResult),
LinkFormat(LinkFormat),
WorkspaceConfig(WorkspaceConfig),
ConvertLinksResult(ConvertLinksResult),
CreateChildResult(CreateChildResult),
LinkParserResult(LinkParserResult),
PluginResult(JsonValue),
PluginManifests(Vec<crate::plugin::PluginManifest>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct EntryData {
pub path: PathBuf,
pub title: Option<String>,
pub frontmatter: IndexMap<String, YamlValue>,
pub content: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct CreateEntryOptions {
pub title: Option<String>,
pub part_of: Option<String>,
pub template: Option<String>,
#[serde(default)]
pub root_index_path: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct SearchOptions {
pub workspace_path: Option<String>,
#[serde(default)]
pub search_frontmatter: bool,
pub property: Option<String>,
#[serde(default)]
pub case_sensitive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct ExportedFile {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct BinaryExportFile {
pub path: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct BinaryFileInfo {
pub source_path: String,
pub relative_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct StorageInfo {
pub used: u64,
pub limit: Option<u64>,
pub attachment_limit: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct FixSummary {
pub error_fixes: Vec<FixResult>,
pub warning_fixes: Vec<FixResult>,
pub total_fixed: usize,
pub total_failed: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct AncestorAttachmentEntry {
pub entry_path: String,
pub entry_title: Option<String>,
pub attachments: Vec<ResolvedAttachmentRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct AncestorAttachmentsResult {
pub entries: Vec<AncestorAttachmentEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct ResolvedAttachmentRef {
pub note_path: String,
pub attachment_path: String,
#[cfg_attr(feature = "typescript", ts(optional))]
#[serde(skip_serializing_if = "Option::is_none")]
pub note_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct EffectiveAudienceResult {
pub tags: Vec<String>,
pub inherited: bool,
#[cfg_attr(feature = "typescript", ts(optional))]
#[serde(skip_serializing_if = "Option::is_none")]
pub source_title: Option<String>,
pub can_inherit: bool,
pub default_audience_applied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct ConvertLinksResult {
pub files_modified: usize,
pub links_converted: usize,
pub modified_files: Vec<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(tag = "type", content = "params", rename_all = "snake_case")]
pub enum LinkParserOperation {
Parse {
link: String,
},
ToCanonical {
link: String,
current_file_path: String,
#[serde(default)]
link_format_hint: Option<LinkFormat>,
},
Format {
canonical_path: String,
title: String,
format: LinkFormat,
from_canonical_path: String,
},
Convert {
link: String,
target_format: LinkFormat,
current_file_path: String,
#[serde(default)]
source_format_hint: Option<LinkFormat>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(rename_all = "snake_case")]
pub enum LinkPathType {
WorkspaceRoot,
Relative,
Ambiguous,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct ParsedLinkResult {
pub title: Option<String>,
pub path: String,
pub path_type: LinkPathType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum LinkParserResult {
Parsed(ParsedLinkResult),
String(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_serialization() {
let cmd = Command::GetEntry {
path: "notes/hello.md".to_string(),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("GetEntry"));
assert!(json.contains("notes/hello.md"));
let cmd2: Command = serde_json::from_str(&json).unwrap();
if let Command::GetEntry { path } = cmd2 {
assert_eq!(path, "notes/hello.md");
} else {
panic!("Wrong command type");
}
}
#[test]
fn test_response_serialization() {
let resp = Response::String("hello".to_string());
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("String"));
assert!(json.contains("hello"));
let resp2: Response = serde_json::from_str(&json).unwrap();
if let Response::String(s) = resp2 {
assert_eq!(s, "hello");
} else {
panic!("Wrong response type");
}
}
#[test]
fn test_create_entry_options_default() {
let opts = CreateEntryOptions::default();
assert!(opts.title.is_none());
assert!(opts.part_of.is_none());
assert!(opts.template.is_none());
}
#[test]
fn test_search_options_default() {
let opts = SearchOptions::default();
assert!(!opts.search_frontmatter);
assert!(!opts.case_sensitive);
assert!(opts.property.is_none());
}
#[test]
fn test_normalize_paths_normalizes_entry_path() {
let mut cmd = Command::GetEntry {
path: "/workspace/notes/day.md".to_string(),
};
cmd.normalize_paths(|p| p.trim_start_matches("/workspace/").to_string());
match cmd {
Command::GetEntry { path } => {
assert_eq!(path, "notes/day.md");
}
other => panic!("Expected GetEntry, got {:?}", other),
}
}
}