obsidian_logging/commands/
add.rs

1use crate::config::{Config, ListType};
2use crate::template::get_template_content;
3use crate::utils::{extract_log_entries, format_time, get_log_path_for_date, parse_time};
4use chrono::{Duration, Local, NaiveTime, Timelike};
5use std::fs::{create_dir_all, read_to_string, write};
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(['-', '*', ' ']);
23
24    // Try to find a valid time pattern at the beginning
25    // This handles both 24-hour (HH:MM:SS) and 12-hour (HH:MM:SS AM/PM) formats
26    let time_patterns = [
27        // 24-hour format: HH:MM:SS
28        r"^(\d{1,2}:\d{2}:\d{2})\s+(.+)$",
29        // 24-hour format: HH:MM (backward compatibility)
30        r"^(\d{1,2}:\d{2})\s+(.+)$",
31        // 12-hour format: HH:MM:SS AM/PM
32        r"^(\d{1,2}:\d{2}:\d{2}\s+[AaPp][Mm])\s+(.+)$",
33        // 12-hour format: HH:MM AM/PM (backward compatibility)
34        r"^(\d{1,2}:\d{2}\s+[AaPp][Mm])\s+(.+)$",
35    ];
36
37    for pattern in &time_patterns {
38        if let Ok(regex) = regex::Regex::new(pattern) {
39            if let Some(captures) = regex.captures(content) {
40                let time = captures.get(1).unwrap().as_str().trim();
41                let entry = captures.get(2).unwrap().as_str().trim();
42                return Some((time.to_string(), entry.to_string()));
43            }
44        }
45    }
46
47    // Fallback to original behavior for backward compatibility
48    if let Some(space_pos) = content.find(' ') {
49        let (time, entry) = content.split_at(space_pos);
50        return Some((time.trim().to_string(), entry.trim().to_string()));
51    }
52
53    None
54}
55
56pub fn handle_with_time(
57    mut args: impl Iterator<Item = String>,
58    config: &Config,
59    silent: bool,
60    category: Option<&str>,
61) {
62    let time_str = args.next().expect("Expected time as first argument");
63    let mut sentence_parts = Vec::new();
64
65    // Check if next word is AM/PM
66    if let Some(next_word) = args.next() {
67        if next_word.eq_ignore_ascii_case("am") || next_word.eq_ignore_ascii_case("pm") {
68            let time_with_period = format!("{} {}", time_str, next_word);
69            if let Some(time) = parse_time(&time_with_period) {
70                sentence_parts.extend(args);
71                handle_plain_entry_with_time(sentence_parts, Some(time), config, silent, category);
72                return;
73            } else {
74                // If time parsing failed with AM/PM, treat both as part of the sentence
75                sentence_parts.push(time_str);
76                sentence_parts.push(next_word);
77                sentence_parts.extend(args);
78                handle_plain_entry_with_time(sentence_parts, None, config, silent, category);
79                return;
80            }
81        } else {
82            sentence_parts.push(next_word);
83        }
84    }
85
86    // Try parsing time without AM/PM
87    if let Some(time) = parse_time(&time_str) {
88        sentence_parts.extend(args);
89        handle_plain_entry_with_time(sentence_parts, Some(time), config, silent, category);
90    } else {
91        // If time parsing failed, treat first argument as part of the sentence
92        sentence_parts.insert(0, time_str);
93        sentence_parts.extend(args);
94        handle_plain_entry_with_time(sentence_parts, None, config, silent, category);
95    }
96}
97
98pub fn handle_plain_entry(
99    first_arg: String,
100    args: impl Iterator<Item = String>,
101    config: &Config,
102    silent: bool,
103    category: Option<&str>,
104) {
105    let mut sentence_parts = vec![first_arg];
106    sentence_parts.extend(args);
107    handle_plain_entry_with_time(sentence_parts, None, config, silent, category);
108}
109
110pub fn handle_plain_entry_with_time(
111    sentence_parts: Vec<String>,
112    time_override: Option<NaiveTime>,
113    config: &Config,
114    silent: bool,
115    category: Option<&str>,
116) {
117    let sentence = sentence_parts.join(" ");
118    let now = Local::now();
119    let date = now.date_naive();
120    let time = time_override.unwrap_or_else(|| {
121        NaiveTime::from_hms_opt(now.hour(), now.minute(), now.second()).unwrap()
122    });
123
124    let file_path = get_log_path_for_date(date, config);
125    create_dir_all(file_path.parent().unwrap()).expect("Could not create log directory");
126
127    let is_new_file = !file_path.exists();
128    let content = if is_new_file {
129        get_template_content(config)
130    } else {
131        read_to_string(&file_path).unwrap_or_default()
132    };
133
134    let section_header = config.get_section_header_for_category(category);
135    let (before_log, after_log, entries, detected_type) =
136        extract_log_entries(&content, section_header, &config.list_type, config, false);
137
138    // For new files, always use the config list type
139    // For existing files, use detected type unless there are no entries
140    let effective_type = if is_new_file || entries.is_empty() {
141        config.list_type.clone()
142    } else {
143        detected_type
144    };
145
146    // Parse all entries into (timestamp, entry) pairs
147    let parsed_entries: Vec<(String, String)> = entries
148        .iter()
149        .filter_map(|e| {
150            if e.starts_with("| ") {
151                parse_table_row(e)
152            } else if e.starts_with("- ") || e.starts_with("* ") {
153                parse_bullet_entry(e)
154            } else {
155                None
156            }
157        })
158        .collect();
159
160    // Normalize all existing timestamps to the current format for consistent comparison
161    // This ensures we can properly detect duplicates even when formats differ
162    let normalized_existing: Vec<(NaiveTime, String)> = parsed_entries
163        .iter()
164        .filter_map(|(time_str, entry)| parse_time(time_str).map(|t| (t, entry.clone())))
165        .collect();
166
167    // Find a unique timestamp by incrementing seconds if needed
168    let mut final_time = time;
169
170    // Check if timestamp already exists and increment seconds until unique
171    // Compare NaiveTime values to handle cases where formats differ
172    while normalized_existing
173        .iter()
174        .any(|(existing_time, _)| *existing_time == final_time)
175    {
176        // Increment by 1 second using chrono's Duration
177        final_time += Duration::seconds(1);
178    }
179
180    // Combine existing entries (with their parsed timestamps) and the new entry,
181    // then normalize all to the current format
182    let mut all_entries: Vec<(NaiveTime, String)> = normalized_existing;
183    all_entries.push((final_time, sentence.clone()));
184
185    // Sort entries by timestamp
186    all_entries.sort_by(|a, b| a.0.cmp(&b.0));
187
188    // Normalize all timestamps to include seconds and use current format
189    // This ensures existing entries without seconds get reformatted with seconds
190    let normalized_entries: Vec<(String, String)> = all_entries
191        .iter()
192        .map(|(parsed_time, entry)| {
193            let normalized_time = format_time(*parsed_time, &config.time_format);
194            (normalized_time, entry.clone())
195        })
196        .collect();
197
198    // Format entries according to effective type
199    let formatted_entries = match effective_type {
200        ListType::Bullet => normalized_entries
201            .into_iter()
202            .map(|(time, entry)| format!("* {} {}", time, entry))
203            .collect(),
204        ListType::Table => {
205            // Calculate maximum widths
206            let mut max_time_width = config.time_label.len();
207            let mut max_entry_width = config.event_label.len();
208
209            for (time, entry) in &normalized_entries {
210                max_time_width = max_time_width.max(time.len());
211                max_entry_width = max_entry_width.max(entry.len());
212            }
213
214            // Format table
215            let mut table = Vec::new();
216            // Always show header for table format
217            table.push(format!(
218                "| {} | {} |",
219                config.time_label, config.event_label
220            ));
221            table.push(format!(
222                "| {} | {} |",
223                "-".repeat(max_time_width),
224                "-".repeat(max_entry_width)
225            ));
226            table.extend(
227                normalized_entries
228                    .into_iter()
229                    .map(|(time, entry)| format!("| {} | {} |", time, entry)),
230            );
231            table
232        }
233    };
234
235    let new_content = format!(
236        "{}{}\n\n{}\n{}",
237        before_log,
238        section_header,
239        formatted_entries.join("\n"),
240        if after_log.is_empty() {
241            String::new()
242        } else {
243            format!("\n{}", after_log)
244        }
245    );
246
247    write(&file_path, new_content.trim_end().to_string() + "\n")
248        .expect("Error writing logs to file");
249
250    if !silent {
251        println!("Logged.");
252    }
253}