kimun-notes 0.24.6

A terminal-based notes application
Documentation
use chrono::Utc;
use kimun_core::NoteVault;
use kimun_core::nfs::NoteEntryData;
use kimun_core::nfs::VaultPath;
use kimun_core::note::{NoteContentData, NoteHeading, NoteMetadata, PropertyEntry};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JsonHeader {
    pub level: u32,
    pub text: String,
}

impl From<NoteHeading> for JsonHeader {
    fn from(heading: NoteHeading) -> Self {
        Self {
            level: heading.level.into(),
            text: heading.text,
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct JsonOutputMetadata {
    pub workspace: String,
    pub workspace_path: String,
    pub total_results: usize,
    pub query: Option<String>,
    pub is_listing: bool,
    pub generated_at: String,
}

/// A note's frontmatter properties, serialized as one JSON object whose
/// keys keep the note's order (`{"status": "done", "priority": 2}`); a key
/// with no readable value (YAML `due:`, a TOML time of day) is `null`.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct JsonProperties(pub Vec<PropertyEntry>);

impl Serialize for JsonProperties {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::SerializeMap;
        let mut map = serializer.serialize_map(Some(self.0.len()))?;
        for (key, value) in &self.0 {
            map.serialize_entry(key, value)?;
        }
        map.end()
    }
}

/// Nested note-level metadata extracted from content
#[derive(Debug, Serialize, Deserialize)]
pub struct JsonNoteMetadata {
    pub tags: Vec<String>,
    pub links: Vec<String>,
    pub headers: Vec<JsonHeader>,
    /// Frontmatter properties. Output only.
    #[serde(skip_deserializing, default)]
    pub properties: JsonProperties,
}

impl JsonNoteMetadata {
    /// Everything the JSON output reports about a note's own text: labels,
    /// links, headings and frontmatter properties.
    pub fn from_content(content: &str) -> Self {
        let meta = NoteMetadata::of(content);
        Self {
            tags: meta.tags,
            links: meta.links,
            headers: meta.headings.into_iter().map(JsonHeader::from).collect(),
            properties: JsonProperties(meta.properties),
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct JsonNoteEntry {
    pub path: String,
    pub title: String,
    pub content: String,
    pub size: u64,
    pub modified: u64,
    pub created: u64,
    pub hash: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub journal_date: Option<String>,
    pub metadata: JsonNoteMetadata,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub backlinks: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct JsonOutput {
    pub metadata: JsonOutputMetadata,
    pub notes: Vec<JsonNoteEntry>,
}

/// Format note entries with their content as JSON output.
pub fn format_notes_with_content_as_json(
    vault: &NoteVault,
    entries: &[(NoteEntryData, NoteContentData)],
    content_map: &[(VaultPath, String)],
    workspace_name: &str,
    workspace_path: &str,
    query: Option<&str>,
    is_listing: bool,
) -> Result<String, Box<dyn std::error::Error>> {
    let output_metadata = JsonOutputMetadata {
        workspace: workspace_name.to_string(),
        workspace_path: workspace_path.to_string(),
        total_results: entries.len(),
        query: query.map(|q| q.to_string()),
        is_listing,
        generated_at: Utc::now().to_rfc3339(),
    };

    let content_lookup: HashMap<String, &str> = content_map
        .iter()
        .map(|(p, c)| (p.to_string(), c.as_str()))
        .collect();

    let notes = entries
        .iter()
        .map(|(entry_data, content_data)| {
            let path_str = entry_data.path.to_string();
            let path_with_ext = entry_data.path.to_string_with_ext();

            let content: &str = content_lookup.get(&path_str).copied().unwrap_or("");

            // Detect journal date using vault
            let journal_date = vault
                .journal_date(&entry_data.path)
                .map(|d| d.format("%Y-%m-%d").to_string());

            // Use modified as created (fallback until created timestamp is tracked separately)
            let created = entry_data.modified_secs;

            JsonNoteEntry {
                path: path_with_ext,
                title: content_data.title.clone(),
                content: content.to_owned(),
                size: entry_data.size,
                modified: entry_data.modified_secs,
                created,
                hash: format!("{:x}", content_data.hash),
                journal_date,
                metadata: JsonNoteMetadata::from_content(content),
                backlinks: None,
            }
        })
        .collect();

    let output = JsonOutput {
        metadata: output_metadata,
        notes,
    };
    Ok(serde_json::to_string(&output)?)
}

/// Format note entries as JSON output, fetching note content from vault
pub async fn format_notes_as_json(
    vault: &NoteVault,
    entries: &[(NoteEntryData, NoteContentData)],
    workspace_name: &str,
    query: Option<&str>,
    is_listing: bool,
) -> Result<String, Box<dyn std::error::Error>> {
    let workspace_path = vault.workspace_path().to_string();

    // Fetch actual content for each note concurrently — bounded so a vault
    // with thousands of entries doesn't open thousands of file descriptors.
    const JSON_FETCH_CONCURRENCY: usize = 32;
    use futures::stream::StreamExt;
    let content_results: Vec<_> = futures::stream::iter(entries.iter().map(|(entry_data, _)| {
        let path = entry_data.path.clone();
        async move {
            match vault.get_note_text(&path).await {
                Ok(content) => Some((path, content)),
                Err(_) => None,
            }
        }
    }))
    .buffered(JSON_FETCH_CONCURRENCY)
    .collect()
    .await;
    let content_map: Vec<_> = content_results.into_iter().flatten().collect();

    format_notes_with_content_as_json(
        vault,
        entries,
        &content_map,
        workspace_name,
        &workspace_path,
        query,
        is_listing,
    )
}