obsidian_logging/commands/
add.rs

1use chrono::{Local, NaiveTime, Timelike};
2use std::fs::{create_dir_all, read_to_string, write};
3use crate::config::{Config, ListType};
4use crate::utils::{get_log_path_for_date, extract_log_entries, format_time, parse_time};
5use crate::template::get_template_content;
6
7/// Parse a table row into (timestamp, entry)
8fn parse_table_row(line: &str) -> Option<(String, String)> {
9    let parts: Vec<&str> = line.split('|').collect();
10    if parts.len() >= 4 {
11        let time = parts[1].trim();
12        let entry = parts[2].trim();
13        if !time.is_empty() && !entry.is_empty() {
14            return Some((time.to_string(), entry.to_string()));
15        }
16    }
17    None
18}
19
20/// Parse a bullet entry into (timestamp, entry)
21fn parse_bullet_entry(line: &str) -> Option<(String, String)> {
22    let content = line.trim_start_matches(|c| c == '-' || c == '*' || c == ' ');
23    
24    // Try to find a valid time pattern at the beginning
25    // This handles both 24-hour (HH:MM) and 12-hour (HH:MM AM/PM) formats
26    let time_patterns = [
27        // 24-hour format: HH:MM
28        r"^(\d{1,2}:\d{2})\s+(.+)$",
29        // 12-hour format: HH:MM AM/PM
30        r"^(\d{1,2}:\d{2}\s+[AaPp][Mm])\s+(.+)$",
31    ];
32    
33    for pattern in &time_patterns {
34        if let Ok(regex) = regex::Regex::new(pattern) {
35            if let Some(captures) = regex.captures(content) {
36                let time = captures.get(1).unwrap().as_str().trim();
37                let entry = captures.get(2).unwrap().as_str().trim();
38                return Some((time.to_string(), entry.to_string()));
39            }
40        }
41    }
42    
43    // Fallback to original behavior for backward compatibility
44    if let Some(space_pos) = content.find(' ') {
45        let (time, entry) = content.split_at(space_pos);
46        return Some((time.trim().to_string(), entry.trim().to_string()));
47    }
48    
49    None
50}
51
52pub fn handle_with_time(mut args: impl Iterator<Item=String>, config: &Config, silent: bool, category: Option<&str>) {
53    let time_str = args.next().expect("Expected time as first argument");
54    let mut sentence_parts = Vec::new();
55
56    // Check if next word is AM/PM
57    if let Some(next_word) = args.next() {
58        if next_word.eq_ignore_ascii_case("am") || next_word.eq_ignore_ascii_case("pm") {
59            let time_with_period = format!("{} {}", time_str, next_word);
60            if let Some(time) = parse_time(&time_with_period) {
61                sentence_parts.extend(args);
62                handle_plain_entry_with_time(sentence_parts, Some(time), config, silent, category);
63                return;
64            } else {
65                // If time parsing failed with AM/PM, treat both as part of the sentence
66                sentence_parts.push(time_str);
67                sentence_parts.push(next_word);
68                sentence_parts.extend(args);
69                handle_plain_entry_with_time(sentence_parts, None, config, silent, category);
70                return;
71            }
72        } else {
73            sentence_parts.push(next_word);
74        }
75    }
76
77    // Try parsing time without AM/PM
78    if let Some(time) = parse_time(&time_str) {
79        sentence_parts.extend(args);
80        handle_plain_entry_with_time(sentence_parts, Some(time), config, silent, category);
81    } else {
82        // If time parsing failed, treat first argument as part of the sentence
83        sentence_parts.insert(0, time_str);
84        sentence_parts.extend(args);
85        handle_plain_entry_with_time(sentence_parts, None, config, silent, category);
86    }
87}
88
89pub fn handle_plain_entry(first_arg: String, args: impl Iterator<Item=String>, config: &Config, silent: bool, category: Option<&str>) {
90    let mut sentence_parts = vec![first_arg];
91    sentence_parts.extend(args);
92    handle_plain_entry_with_time(sentence_parts, None, config, silent, category);
93}
94
95pub fn handle_plain_entry_with_time(sentence_parts: Vec<String>, time_override: Option<NaiveTime>, config: &Config, silent: bool, category: Option<&str>) {
96    let sentence = sentence_parts.join(" ");
97    let now = Local::now();
98    let date = now.date_naive();
99    let time = time_override.unwrap_or_else(|| NaiveTime::from_hms_opt(now.hour(), now.minute(), 0).unwrap());
100    let time_str = format_time(time, &config.time_format);
101
102    let file_path = get_log_path_for_date(date, config);
103    create_dir_all(file_path.parent().unwrap()).expect("Could not create log directory");
104
105    let is_new_file = !file_path.exists();
106    let content = if is_new_file {
107        get_template_content(config)
108    } else {
109        read_to_string(&file_path).unwrap_or_default()
110    };
111
112    let section_header = config.get_section_header_for_category(category);
113    let (before_log, after_log, entries, detected_type) = extract_log_entries(&content, section_header, &config.list_type, config, false);
114
115    // For new files, always use the config list type
116    // For existing files, use detected type unless there are no entries
117    let effective_type = if is_new_file {
118        config.list_type.clone()
119    } else if entries.is_empty() {
120        config.list_type.clone()
121    } else {
122        detected_type
123    };
124
125    // Parse all entries into (timestamp, entry) pairs
126    let mut parsed_entries: Vec<(String, String)> = entries.iter()
127        .filter_map(|e| {
128            if e.starts_with("| ") {
129                parse_table_row(e)
130            } else if e.starts_with("- ") || e.starts_with("* ") {
131                parse_bullet_entry(e)
132            } else {
133                None
134            }
135        })
136        .collect();
137
138    // Add the new entry
139    parsed_entries.push((time_str, sentence));
140
141    // Sort entries by timestamp (convert to NaiveTime for comparison)
142    parsed_entries.sort_by(|a, b| {
143        let time_a = parse_time(&a.0).unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
144        let time_b = parse_time(&b.0).unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
145        time_a.cmp(&time_b)
146    });
147
148    // Format entries according to effective type
149    let formatted_entries = match effective_type {
150        ListType::Bullet => {
151            parsed_entries.into_iter()
152                .map(|(time, entry)| format!("* {} {}", time, entry))
153                .collect()
154        }
155        ListType::Table => {
156            // Calculate maximum widths
157            let mut max_time_width = config.time_label.len();
158            let mut max_entry_width = config.event_label.len();
159
160            for (time, entry) in &parsed_entries {
161                max_time_width = max_time_width.max(time.len());
162                max_entry_width = max_entry_width.max(entry.len());
163            }
164
165            // Format table
166            let mut table = Vec::new();
167            // Always show header for table format
168            table.push(format!("| {} | {} |", config.time_label, config.event_label));
169            table.push(format!("| {} | {} |", 
170                "-".repeat(max_time_width),
171                "-".repeat(max_entry_width)
172            ));
173            table.extend(parsed_entries.into_iter().map(|(time, entry)| {
174                format!("| {} | {} |", time, entry)
175            }));
176            table
177        }
178    };
179
180    let new_content = format!(
181        "{}{}\n\n{}\n{}",
182        before_log,
183        section_header,
184        formatted_entries.join("\n"),
185        if after_log.is_empty() {
186            String::new()
187        } else {
188            format!("\n{}", after_log)
189        }
190    );
191
192    write(&file_path, new_content.trim_end().to_string() + "\n").expect("Error writing logs to file");
193
194    if !silent {
195        println!("Logged.");
196    }
197}
198
199
200