Skip to main content

kimun_notes/cli/
json_output.rs

1use std::collections::HashMap;
2use chrono::Utc;
3use kimun_core::nfs::NoteEntryData;
4use kimun_core::note::NoteContentData;
5use kimun_core::nfs::VaultPath;
6use kimun_core::NoteVault;
7use serde::{Deserialize, Serialize};
8use crate::cli::metadata_extractor::{extract_tags, extract_links, extract_headers};
9
10#[derive(Debug, Serialize, Deserialize, Clone)]
11pub struct JsonHeader {
12    pub level: u32,
13    pub text: String,
14}
15
16#[derive(Debug, Serialize, Deserialize)]
17pub struct JsonOutputMetadata {
18    pub workspace: String,
19    pub workspace_path: String,
20    pub total_results: usize,
21    pub query: Option<String>,
22    pub is_listing: bool,
23    pub generated_at: String,
24}
25
26/// Nested note-level metadata extracted from content
27#[derive(Debug, Serialize, Deserialize)]
28pub struct JsonNoteMetadata {
29    pub tags: Vec<String>,
30    pub links: Vec<String>,
31    pub headers: Vec<JsonHeader>,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35pub struct JsonNoteEntry {
36    pub path: String,
37    pub title: String,
38    pub content: String,
39    pub size: u64,
40    pub modified: u64,
41    pub created: u64,
42    pub hash: String,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub journal_date: Option<String>,
45    pub metadata: JsonNoteMetadata,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub backlinks: Option<Vec<String>>,
48}
49
50#[derive(Debug, Serialize, Deserialize)]
51pub struct JsonOutput {
52    pub metadata: JsonOutputMetadata,
53    pub notes: Vec<JsonNoteEntry>,
54}
55
56/// Appends `.md` to a path string if it does not already end with it.
57pub fn ensure_md_extension(path: &str) -> String {
58    if path.ends_with(".md") {
59        path.to_owned()
60    } else {
61        format!("{}.md", path)
62    }
63}
64
65/// Format note entries with their content as JSON output.
66pub fn format_notes_with_content_as_json(
67    vault: &NoteVault,
68    entries: &[(NoteEntryData, NoteContentData)],
69    content_map: &[(VaultPath, String)],
70    workspace_name: &str,
71    workspace_path: &str,
72    query: Option<&str>,
73    is_listing: bool,
74) -> Result<String, Box<dyn std::error::Error>> {
75    let output_metadata = JsonOutputMetadata {
76        workspace: workspace_name.to_string(),
77        workspace_path: workspace_path.to_string(),
78        total_results: entries.len(),
79        query: query.map(|q| q.to_string()),
80        is_listing,
81        generated_at: Utc::now().to_rfc3339(),
82    };
83
84    let content_lookup: HashMap<String, &str> = content_map
85        .iter()
86        .map(|(p, c)| (p.to_string(), c.as_str()))
87        .collect();
88
89    let notes = entries
90        .iter()
91        .map(|(entry_data, content_data)| {
92            let path_str = entry_data.path.to_string();
93            let path_with_ext = entry_data.path.to_string_with_ext();
94
95            let content: &str = content_lookup.get(&path_str).copied().unwrap_or("");
96
97            let tags = extract_tags(content);
98            let links = extract_links(content);
99            let headers = extract_headers(content);
100
101            // Detect journal date using vault
102            let journal_date = vault
103                .journal_date(&entry_data.path)
104                .map(|d| d.format("%Y-%m-%d").to_string());
105
106            // Use modified as created (fallback until created timestamp is tracked separately)
107            let created = entry_data.modified_secs;
108
109            JsonNoteEntry {
110                path: path_with_ext,
111                title: content_data.title.clone(),
112                content: content.to_owned(),
113                size: entry_data.size,
114                modified: entry_data.modified_secs,
115                created,
116                hash: format!("{:x}", content_data.hash),
117                journal_date,
118                metadata: JsonNoteMetadata {
119                    tags,
120                    links,
121                    headers,
122                },
123                backlinks: None,
124            }
125        })
126        .collect();
127
128    let output = JsonOutput { metadata: output_metadata, notes };
129    Ok(serde_json::to_string(&output)?)
130}
131
132/// Format note entries as JSON output, fetching note content from vault
133pub async fn format_notes_as_json(
134    vault: &NoteVault,
135    entries: &[(NoteEntryData, NoteContentData)],
136    workspace_name: &str,
137    query: Option<&str>,
138    is_listing: bool,
139) -> Result<String, Box<dyn std::error::Error>> {
140    let workspace_path = vault.workspace_path.to_string_lossy().to_string();
141
142    // Fetch actual content for each note concurrently to enable metadata extraction
143    let content_futures: Vec<_> = entries
144        .iter()
145        .map(|(entry_data, _)| async {
146            let path = entry_data.path.clone();
147            match vault.get_note_text(&path).await {
148                Ok(content) => Some((path, content)),
149                Err(_) => None,
150            }
151        })
152        .collect();
153
154    let content_results = futures::future::join_all(content_futures).await;
155    let content_map: Vec<_> = content_results.into_iter().flatten().collect();
156
157    format_notes_with_content_as_json(
158        vault,
159        entries,
160        &content_map,
161        workspace_name,
162        &workspace_path,
163        query,
164        is_listing,
165    )
166}