obsidian_logging/
utils.rs

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