Skip to main content

kimun_notes/cli/commands/
journal.rs

1// tui/src/cli/commands/journal.rs
2//
3// Top-level `kimun journal` command: append to and show journal entries.
4
5use clap::Subcommand;
6use color_eyre::eyre::Result;
7use kimun_core::{NoteVault, nfs::VaultPath};
8
9use crate::cli::output::OutputFormat;
10
11#[derive(clap::Args, Debug)]
12pub struct JournalArgs {
13    /// Date in YYYY-MM-DD format (defaults to today)
14    #[arg(long)]
15    pub date: Option<String>,
16    /// Text to append (reads from stdin if omitted and stdin is not a TTY)
17    pub content: Option<String>,
18    #[command(subcommand)]
19    pub subcommand: Option<JournalSubcommand>,
20}
21
22#[derive(Subcommand, Debug)]
23pub enum JournalSubcommand {
24    /// Show a journal entry
25    Show {
26        /// Date in YYYY-MM-DD format (defaults to today)
27        #[arg(long)]
28        date: Option<String>,
29        /// Output format
30        #[arg(long, value_enum, default_value = "text")]
31        format: OutputFormat,
32    },
33}
34
35pub async fn run(args: JournalArgs, vault: &NoteVault, workspace_name: &str) -> Result<()> {
36    match args.subcommand {
37        Some(JournalSubcommand::Show { date, format }) => {
38            run_show(vault, date.as_deref(), format, workspace_name).await
39        }
40        None => run_append(vault, args.date.as_deref(), args.content).await,
41    }
42}
43
44/// Validate and return a `YYYY-MM-DD` date string. Defaults to today when `None`.
45fn resolve_date(date: Option<&str>) -> Result<String> {
46    match date {
47        None => Ok(chrono::Utc::now().format("%Y-%m-%d").to_string()),
48        Some(d) => {
49            chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").map_err(|_| {
50                color_eyre::eyre::eyre!("Invalid date '{}' — expected format YYYY-MM-DD", d)
51            })?;
52            Ok(d.to_string())
53        }
54    }
55}
56
57/// Build the vault path for a journal entry using the vault's configured journal path.
58fn journal_entry_path(vault: &NoteVault, date_str: &str) -> VaultPath {
59    vault
60        .journal_path()
61        .append(&VaultPath::note_path_from(date_str))
62        .absolute()
63}
64
65async fn run_append(vault: &NoteVault, date: Option<&str>, content: Option<String>) -> Result<()> {
66    use crate::cli::helpers::resolve_content;
67
68    let text = resolve_content(content)?;
69    if text.is_empty() {
70        return Ok(());
71    }
72
73    let (vault_path, existing) = match date {
74        None => {
75            // Today — journal_entry() handles create-if-absent internally.
76            let (details, existing) = vault
77                .journal_entry()
78                .await
79                .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
80            (details.path, existing)
81        }
82        Some(d) => {
83            let date_str = resolve_date(Some(d))?;
84            let vault_path = journal_entry_path(vault, &date_str);
85            let existing = vault
86                .load_or_create_note(&vault_path, Some(format!("# {}\n\n", date_str)))
87                .await
88                .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
89            (vault_path, existing)
90        }
91    };
92
93    let combined = format!("{}\n{}", existing, text);
94    vault
95        .save_note(&vault_path, &combined)
96        .await
97        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
98
99    println!("Note saved: {}", vault_path);
100    Ok(())
101}
102
103async fn run_show(
104    vault: &NoteVault,
105    date: Option<&str>,
106    format: OutputFormat,
107    workspace_name: &str,
108) -> Result<()> {
109    use crate::cli::commands::note_ops::format_note_show_text;
110    use crate::cli::json_output::{
111        JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata,
112    };
113    use crate::cli::metadata_extractor::{extract_headers, extract_links, extract_tags};
114    use chrono::Utc;
115    use kimun_core::error::{FSError, VaultError};
116    use std::time::UNIX_EPOCH;
117
118    if matches!(format, OutputFormat::Paths) {
119        return Err(color_eyre::eyre::eyre!(
120            "--format paths is not valid for journal show; use 'text' or 'json'"
121        ));
122    }
123
124    let date_str = resolve_date(date)?;
125    let vault_path = journal_entry_path(vault, &date_str);
126
127    let note_details = vault.load_note(&vault_path).await.map_err(|e| match e {
128        VaultError::FSError(FSError::VaultPathNotFound { .. }) => {
129            color_eyre::eyre::eyre!("No journal entry found for {}", date_str)
130        }
131        _ => color_eyre::eyre::eyre!("{}", e),
132    })?;
133
134    let content = &note_details.raw_text;
135    let content_data = note_details.get_content_data();
136
137    let backlinks = vault
138        .get_backlinks(&vault_path)
139        .await
140        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
141    let backlink_paths: Vec<String> = backlinks.iter().map(|(e, _)| e.path.to_string()).collect();
142
143    match format {
144        OutputFormat::Text => {
145            let tags = extract_tags(content);
146            let links = extract_links(content);
147            print!(
148                "{}",
149                format_note_show_text(
150                    &vault_path,
151                    content,
152                    &content_data.title,
153                    &tags,
154                    &links,
155                    &backlink_paths,
156                )
157            );
158        }
159        OutputFormat::Json => {
160            let meta = tokio::fs::metadata(vault.path_to_pathbuf(&vault_path))
161                .await
162                .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
163            let modified_secs = meta
164                .modified()
165                .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
166                .unwrap_or(0);
167            let tags = extract_tags(content);
168            let links = extract_links(content);
169            let headers = extract_headers(content);
170            let journal_date = vault
171                .journal_date(&vault_path)
172                .map(|d| d.format("%Y-%m-%d").to_string());
173            let entry = JsonNoteEntry {
174                path: vault_path.to_string_with_ext(),
175                title: content_data.title.clone(),
176                content: content.clone(),
177                size: meta.len(),
178                modified: modified_secs,
179                created: modified_secs,
180                hash: format!("{:x}", content_data.hash),
181                journal_date,
182                metadata: JsonNoteMetadata {
183                    tags,
184                    links,
185                    headers,
186                },
187                backlinks: if backlink_paths.is_empty() {
188                    None
189                } else {
190                    Some(backlink_paths)
191                },
192            };
193            let output = JsonOutput {
194                metadata: JsonOutputMetadata {
195                    workspace: workspace_name.to_string(),
196                    workspace_path: vault.workspace_path().to_string_lossy().to_string(),
197                    total_results: 1,
198                    query: None,
199                    is_listing: false,
200                    generated_at: Utc::now().to_rfc3339(),
201                },
202                notes: vec![entry],
203            };
204            print!(
205                "{}",
206                serde_json::to_string(&output).map_err(|e| color_eyre::eyre::eyre!("{}", e))?
207            );
208        }
209        OutputFormat::Paths => unreachable!("guarded above"),
210    }
211
212    Ok(())
213}