Skip to main content

kimun_notes/cli/
json_output.rs

1use chrono::Utc;
2use kimun_core::NoteVault;
3use kimun_core::nfs::NoteEntryData;
4use kimun_core::nfs::VaultPath;
5use kimun_core::note::{NoteContentData, NoteHeading, NoteMetadata, PropertyEntry};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Serialize, Deserialize, Clone)]
10pub struct JsonHeader {
11    pub level: u32,
12    pub text: String,
13}
14
15impl From<NoteHeading> for JsonHeader {
16    fn from(heading: NoteHeading) -> Self {
17        Self {
18            level: heading.level.into(),
19            text: heading.text,
20        }
21    }
22}
23
24#[derive(Debug, Serialize, Deserialize)]
25pub struct JsonOutputMetadata {
26    pub workspace: String,
27    pub workspace_path: String,
28    pub total_results: usize,
29    pub query: Option<String>,
30    pub is_listing: bool,
31    pub generated_at: String,
32}
33
34/// A note's frontmatter properties, serialized as one JSON object whose
35/// keys keep the note's order (`{"status": "done", "priority": 2}`); a key
36/// with no readable value (YAML `due:`, a TOML time of day) is `null`.
37#[derive(Debug, Default, Clone, PartialEq)]
38pub struct JsonProperties(pub Vec<PropertyEntry>);
39
40impl Serialize for JsonProperties {
41    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
42        use serde::ser::SerializeMap;
43        let mut map = serializer.serialize_map(Some(self.0.len()))?;
44        for (key, value) in &self.0 {
45            map.serialize_entry(key, value)?;
46        }
47        map.end()
48    }
49}
50
51/// Nested note-level metadata extracted from content
52#[derive(Debug, Serialize, Deserialize)]
53pub struct JsonNoteMetadata {
54    pub tags: Vec<String>,
55    pub links: Vec<String>,
56    pub headers: Vec<JsonHeader>,
57    /// Frontmatter properties. Output only.
58    #[serde(skip_deserializing, default)]
59    pub properties: JsonProperties,
60}
61
62impl JsonNoteMetadata {
63    /// Everything the JSON output reports about a note's own text: labels,
64    /// links, headings and frontmatter properties.
65    pub fn from_content(content: &str) -> Self {
66        let meta = NoteMetadata::of(content);
67        Self {
68            tags: meta.tags,
69            links: meta.links,
70            headers: meta.headings.into_iter().map(JsonHeader::from).collect(),
71            properties: JsonProperties(meta.properties),
72        }
73    }
74}
75
76#[derive(Debug, Serialize, Deserialize)]
77pub struct JsonNoteEntry {
78    pub path: String,
79    pub title: String,
80    pub content: String,
81    pub size: u64,
82    pub modified: u64,
83    pub created: u64,
84    pub hash: String,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub journal_date: Option<String>,
87    pub metadata: JsonNoteMetadata,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub backlinks: Option<Vec<String>>,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93pub struct JsonOutput {
94    pub metadata: JsonOutputMetadata,
95    pub notes: Vec<JsonNoteEntry>,
96}
97
98/// Format note entries with their content as JSON output.
99pub fn format_notes_with_content_as_json(
100    vault: &NoteVault,
101    entries: &[(NoteEntryData, NoteContentData)],
102    content_map: &[(VaultPath, String)],
103    workspace_name: &str,
104    workspace_path: &str,
105    query: Option<&str>,
106    is_listing: bool,
107) -> Result<String, Box<dyn std::error::Error>> {
108    let output_metadata = JsonOutputMetadata {
109        workspace: workspace_name.to_string(),
110        workspace_path: workspace_path.to_string(),
111        total_results: entries.len(),
112        query: query.map(|q| q.to_string()),
113        is_listing,
114        generated_at: Utc::now().to_rfc3339(),
115    };
116
117    let content_lookup: HashMap<String, &str> = content_map
118        .iter()
119        .map(|(p, c)| (p.to_string(), c.as_str()))
120        .collect();
121
122    let notes = entries
123        .iter()
124        .map(|(entry_data, content_data)| {
125            let path_str = entry_data.path.to_string();
126            let path_with_ext = entry_data.path.to_string_with_ext();
127
128            let content: &str = content_lookup.get(&path_str).copied().unwrap_or("");
129
130            // Detect journal date using vault
131            let journal_date = vault
132                .journal_date(&entry_data.path)
133                .map(|d| d.format("%Y-%m-%d").to_string());
134
135            // Use modified as created (fallback until created timestamp is tracked separately)
136            let created = entry_data.modified_secs;
137
138            JsonNoteEntry {
139                path: path_with_ext,
140                title: content_data.title.clone(),
141                content: content.to_owned(),
142                size: entry_data.size,
143                modified: entry_data.modified_secs,
144                created,
145                hash: format!("{:x}", content_data.hash),
146                journal_date,
147                metadata: JsonNoteMetadata::from_content(content),
148                backlinks: None,
149            }
150        })
151        .collect();
152
153    let output = JsonOutput {
154        metadata: output_metadata,
155        notes,
156    };
157    Ok(serde_json::to_string(&output)?)
158}
159
160/// Format note entries as JSON output, fetching note content from vault
161pub async fn format_notes_as_json(
162    vault: &NoteVault,
163    entries: &[(NoteEntryData, NoteContentData)],
164    workspace_name: &str,
165    query: Option<&str>,
166    is_listing: bool,
167) -> Result<String, Box<dyn std::error::Error>> {
168    let workspace_path = vault.workspace_path().to_string();
169
170    // Fetch actual content for each note concurrently — bounded so a vault
171    // with thousands of entries doesn't open thousands of file descriptors.
172    const JSON_FETCH_CONCURRENCY: usize = 32;
173    use futures::stream::StreamExt;
174    let content_results: Vec<_> = futures::stream::iter(entries.iter().map(|(entry_data, _)| {
175        let path = entry_data.path.clone();
176        async move {
177            match vault.get_note_text(&path).await {
178                Ok(content) => Some((path, content)),
179                Err(_) => None,
180            }
181        }
182    }))
183    .buffered(JSON_FETCH_CONCURRENCY)
184    .collect()
185    .await;
186    let content_map: Vec<_> = content_results.into_iter().flatten().collect();
187
188    format_notes_with_content_as_json(
189        vault,
190        entries,
191        &content_map,
192        workspace_name,
193        &workspace_path,
194        query,
195        is_listing,
196    )
197}