obsidian_logging/
utils.rs

1use crate::config::{Config, ListType, TimeFormat};
2use chrono::{NaiveDate, NaiveTime, Timelike};
3use lazy_static::lazy_static;
4use regex::Regex;
5use std::path::PathBuf;
6
7lazy_static! {
8    static ref TIME_PATTERN: Regex =
9        Regex::new(r"^(?:[-*]\s*)?(\d{2}:\d{2}(?::\d{2})?(?:\s*[AaPp][Mm])?)\s*(.+)$").unwrap();
10}
11
12/// Format time according to the specified format (12 or 24 hour)
13pub fn format_time(time: NaiveTime, format: &TimeFormat) -> String {
14    match format {
15        TimeFormat::Hour24 => time.format("%H:%M:%S").to_string(),
16        TimeFormat::Hour12 => {
17            let hour = time.hour();
18            let minute = time.minute();
19            let second = time.second();
20            let period = if hour < 12 { "AM" } else { "PM" };
21            let hour12 = match hour {
22                0 => 12,
23                13..=23 => hour - 12,
24                _ => hour,
25            };
26            format!("{:02}:{:02}:{:02} {}", hour12, minute, second, period)
27        }
28    }
29}
30
31/// Parse time string in either 12 or 24 hour format
32/// Supports both HH:MM and HH:MM:SS formats. If seconds are not provided, defaults to 00.
33pub fn parse_time(time_str: &str) -> Option<NaiveTime> {
34    // Try 24-hour format with seconds first
35    // Validate seconds are in range 0-59 before parsing
36    if time_str.matches(':').count() >= 2 {
37        // Has seconds, validate format
38        let parts: Vec<&str> = time_str.split(':').collect();
39        if parts.len() >= 3 {
40            if let Ok(seconds) = parts[2]
41                .split_whitespace()
42                .next()
43                .unwrap_or("")
44                .parse::<u32>()
45            {
46                if seconds >= 60 {
47                    return None;
48                }
49            }
50        }
51    }
52
53    if let Ok(time) = NaiveTime::parse_from_str(time_str, "%H:%M:%S") {
54        return Some(time);
55    }
56
57    // Try 24-hour format without seconds (default to 00 seconds)
58    if let Ok(time) = NaiveTime::parse_from_str(time_str, "%H:%M") {
59        return Some(NaiveTime::from_hms_opt(time.hour(), time.minute(), 0).unwrap());
60    }
61
62    // Try various 12-hour formats with seconds
63    let formats_with_seconds = vec![
64        "%I:%M:%S %p", // "02:30:45 PM"
65        "%I:%M:%S%p",  // "02:30:45PM"
66        "%l:%M:%S %p", // "2:30:45 PM"
67        "%l:%M:%S%p",  // "2:30:45PM"
68    ];
69
70    for format in formats_with_seconds {
71        if let Ok(time) = NaiveTime::parse_from_str(&time_str.to_uppercase(), format) {
72            // Validate that seconds are in valid range (0-59)
73            if time.second() >= 60 {
74                continue;
75            }
76            return Some(time);
77        }
78    }
79
80    // Try various 12-hour formats without seconds (default to 00 seconds)
81    let formats = vec![
82        "%I:%M %p", // "02:30 PM"
83        "%I:%M%p",  // "02:30PM"
84        "%l:%M %p", // "2:30 PM"
85        "%l:%M%p",  // "2:30PM"
86    ];
87
88    for format in formats {
89        if let Ok(time) = NaiveTime::parse_from_str(&time_str.to_uppercase(), format) {
90            return Some(NaiveTime::from_hms_opt(time.hour(), time.minute(), 0).unwrap());
91        }
92    }
93
94    None
95}
96
97/// Build file path for given date and format string from configuration yaml
98/// Supported tokens: {year}, {month}, {date}
99pub fn get_log_path_for_date(date: NaiveDate, config: &Config) -> PathBuf {
100    let mut path = PathBuf::from(&config.vault);
101
102    let year = date.format("%Y").to_string();
103    let month = date.format("%m").to_string();
104    let date_str = date.format("%Y-%m-%d").to_string();
105
106    let file_path = config
107        .file_path_format
108        .replace("{year}", &year)
109        .replace("{month}", &month)
110        .replace("{date}", &date_str);
111
112    path.push(file_path);
113    path
114}
115
116/// Format a table row with given widths for timestamp and entry columns
117fn format_table_row(timestamp: &str, entry: &str, time_width: usize, entry_width: usize) -> String {
118    format!(
119        "| {:<width_t$} | {:<width_e$} |",
120        timestamp,
121        entry,
122        width_t = time_width,
123        width_e = entry_width
124    )
125}
126
127/// Format a table separator line with given column widths
128fn format_table_separator(time_width: usize, entry_width: usize) -> String {
129    format!(
130        "|{}|{}|",
131        "-".repeat(time_width + 2),
132        "-".repeat(entry_width + 2)
133    )
134}
135
136/// Parse an entry to extract timestamp and content
137fn parse_entry(entry: &str) -> (String, String) {
138    if entry.starts_with('|') {
139        // Parse table format
140        let parts: Vec<&str> = entry.split('|').collect();
141        if parts.len() >= 4 {
142            return (parts[1].trim().to_string(), parts[2].trim().to_string());
143        }
144    } else if entry.starts_with(['*', '-']) {
145        // Parse bullet format - handle both 24-hour and 12-hour time formats
146        let content = entry.trim_start_matches(['-', '*', ' ']);
147
148        // Try to find a valid time pattern at the beginning
149        let time_patterns = [
150            // 24-hour format: HH:MM:SS
151            r"^(\d{1,2}:\d{2}:\d{2})\s+(.+)$",
152            // 24-hour format: HH:MM (backward compatibility)
153            r"^(\d{1,2}:\d{2})\s+(.+)$",
154            // 12-hour format: HH:MM:SS AM/PM
155            r"^(\d{1,2}:\d{2}:\d{2}\s+[AaPp][Mm])\s+(.+)$",
156            // 12-hour format: HH:MM AM/PM (backward compatibility)
157            r"^(\d{1,2}:\d{2}\s+[AaPp][Mm])\s+(.+)$",
158        ];
159
160        for pattern in &time_patterns {
161            if let Ok(regex) = Regex::new(pattern) {
162                if let Some(captures) = regex.captures(content) {
163                    let time = captures.get(1).unwrap().as_str().trim();
164                    let entry_text = captures.get(2).unwrap().as_str().trim();
165                    return (time.to_string(), entry_text.to_string());
166                }
167            }
168        }
169
170        // Fallback to original behavior for backward compatibility
171        if let Some(space_pos) = content.find(' ') {
172            if let Some(second_space) = content[space_pos + 1..].find(' ') {
173                return (
174                    content[..space_pos + 1 + second_space].trim().to_string(),
175                    content[space_pos + 1 + second_space + 1..]
176                        .trim()
177                        .to_string(),
178                );
179            }
180        }
181    }
182    (String::new(), String::new())
183}
184
185/// Extract log entries from the log section
186/// Returns ( content before log section, content after log section, list of log entries, and detected list type)
187/// Section heading retrieved from yaml config
188pub fn extract_log_entries(
189    content: &str,
190    section_header: &str,
191    list_type: &ListType,
192    config: &Config,
193    include_header: bool,
194) -> (String, String, Vec<String>, ListType) {
195    let mut before = String::new();
196    let mut after = String::new();
197    let mut entries = Vec::new();
198    let mut found_type = list_type.clone();
199    let mut in_section = false;
200    let mut found_section = false;
201
202    let lines = content.lines().peekable();
203    for line in lines {
204        if line.starts_with(section_header) {
205            found_section = true;
206            in_section = true;
207            before = before.trim_end().to_string() + "\n\n";
208            continue;
209        }
210
211        if in_section {
212            if line.starts_with("##") {
213                in_section = false;
214                after = line.to_string();
215                continue;
216            }
217
218            let trimmed = line.trim();
219            if !trimmed.is_empty() {
220                if trimmed.starts_with('|') {
221                    found_type = ListType::Table;
222                } else if trimmed.starts_with(['*', '-']) {
223                    found_type = ListType::Bullet;
224                }
225
226                // Skip table separator and header rows
227                if !trimmed.contains("---")
228                    && trimmed != format!("| {} | {} |", config.time_label, config.event_label)
229                {
230                    entries.push(line.to_string());
231                }
232            }
233        } else if !found_section {
234            before.push_str(line);
235            before.push('\n');
236        } else if !line.is_empty() {
237            after.push('\n');
238            after.push_str(line);
239        }
240    }
241
242    // Convert entries if needed
243    if found_type != *list_type {
244        let mut converted_entries = Vec::new();
245
246        if *list_type == ListType::Table {
247            // Convert from bullet to table
248            let mut max_time_width = config.time_label.len();
249            let mut max_entry_width = config.event_label.len();
250
251            // First pass: calculate widths
252            for entry in &entries {
253                let (time, text) = parse_entry(entry);
254                // Parse and reformat time according to config
255                let formatted_time = if let Some(parsed_time) = parse_time(&time) {
256                    format_time(parsed_time, &config.time_format)
257                } else {
258                    time
259                };
260                max_time_width = max_time_width.max(formatted_time.len());
261                max_entry_width = max_entry_width.max(text.len());
262            }
263
264            // Add header only if include_header is true
265            if include_header {
266                converted_entries.push(format_table_row(
267                    &config.time_label,
268                    &config.event_label,
269                    max_time_width,
270                    max_entry_width,
271                ));
272                converted_entries.push(format_table_separator(max_time_width, max_entry_width));
273            }
274
275            // Second pass: format entries
276            for entry in entries {
277                let (time, text) = parse_entry(&entry);
278                // Parse and reformat time according to config
279                let formatted_time = if let Some(parsed_time) = parse_time(&time) {
280                    format_time(parsed_time, &config.time_format)
281                } else {
282                    time
283                };
284                converted_entries.push(format_table_row(
285                    &formatted_time,
286                    &text,
287                    max_time_width,
288                    max_entry_width,
289                ));
290            }
291        } else {
292            // Convert from table to bullet
293            // Add table header as a comment only if include_header is true
294            if include_header {
295                converted_entries.push(format!(
296                    "<!-- {} | {} -->",
297                    config.time_label, config.event_label
298                ));
299            }
300
301            for entry in entries {
302                let (time, text) = parse_entry(&entry);
303                if !time.is_empty() && !text.is_empty() {
304                    // Parse and reformat time according to config
305                    let formatted_time = if let Some(parsed_time) = parse_time(&time) {
306                        format_time(parsed_time, &config.time_format)
307                    } else {
308                        time
309                    };
310                    converted_entries.push(format!("- {} {}", formatted_time, text));
311                }
312            }
313        }
314
315        entries = converted_entries;
316    } else if *list_type == ListType::Table
317        && found_type == ListType::Table
318        && include_header
319    {
320        // Format hasn't changed, but ensure table format has proper header
321                // Rebuild table with proper header and separator
322                let mut max_time_width = config.time_label.len();
323                let mut max_entry_width = config.event_label.len();
324
325                // First pass: calculate widths from existing entries
326                for entry in &entries {
327                    let (time, text) = parse_entry(entry);
328                    max_time_width = max_time_width.max(time.len());
329                    max_entry_width = max_entry_width.max(text.len());
330                }
331
332                // Rebuild table with header
333                let mut rebuilt_entries = Vec::new();
334                rebuilt_entries.push(format_table_row(
335                    &config.time_label,
336                    &config.event_label,
337                    max_time_width,
338                    max_entry_width,
339                ));
340                rebuilt_entries.push(format_table_separator(max_time_width, max_entry_width));
341
342                // Add data rows
343                for entry in entries {
344                    let (time, text) = parse_entry(&entry);
345                    if !time.is_empty() && !text.is_empty() {
346                        rebuilt_entries.push(format_table_row(
347                            &time,
348                            &text,
349                            max_time_width,
350                            max_entry_width,
351                        ));
352                    }
353                }
354
355                entries = rebuilt_entries;
356        // If include_header is false, keep original entries as-is
357        // For bullet format, entries are already in the correct format
358    }
359
360    (before, after, entries, found_type)
361}