Skip to main content

journey/
vault.rs

1use crate::config::{VaultConfig, NoteFormat};
2use crate::date_time::DateTimeHandler;
3use crate::errors::JourneyError;
4use chrono::{DateTime, Local, NaiveDate, Datelike, Weekday};
5use std::fs;
6use std::path::PathBuf;
7
8pub struct Vault {
9    pub config: VaultConfig,
10    pub date_handler: DateTimeHandler,
11}
12
13impl Vault {
14    pub fn new(mut config: VaultConfig) -> Self {
15        // Backward-compat: map legacy note_format to list_type when not set
16        if config.list_type.is_none() {
17            if let Some(fmt) = config.note_format.clone() {
18                config.list_type = Some(fmt);
19            }
20        }
21        let date_handler = DateTimeHandler::new(config.locale.clone());
22        Self {
23            config,
24            date_handler,
25        }
26    }
27
28    /// Get locale-dependent table headers
29    pub fn get_table_headers(&self) -> (String, String) {
30        // Check for custom table headers first
31        if let Some(ref custom_headers) = self.config.table_headers {
32            return (custom_headers.time.clone(), custom_headers.content.clone());
33        }
34        
35        let locale = &self.config.locale;
36        
37        // Check for Norwegian locale
38        if locale.starts_with("no_") || locale.starts_with("nb_") || locale.starts_with("nn_") {
39            ("Tid".to_string(), "Innhold".to_string())
40        }
41        // Check for Swedish locale
42        else if locale.starts_with("sv_") {
43            ("Tid".to_string(), "Innehåll".to_string())
44        }
45        // Check for Danish locale
46        else if locale.starts_with("da_") {
47            ("Tid".to_string(), "Indhold".to_string())
48        }
49        // Check for Finnish locale
50        else if locale.starts_with("fi_") {
51            ("Aika".to_string(), "Sisältö".to_string())
52        }
53        // Check for German locale
54        else if locale.starts_with("de_") {
55            ("Zeit".to_string(), "Inhalt".to_string())
56        }
57        // Check for French locale
58        else if locale.starts_with("fr_") {
59            ("Heure".to_string(), "Contenu".to_string())
60        }
61        // Check for Spanish locale
62        else if locale.starts_with("es_") {
63            ("Hora".to_string(), "Contenido".to_string())
64        }
65        // Check for Italian locale
66        else if locale.starts_with("it_") {
67            ("Ora".to_string(), "Contenuto".to_string())
68        }
69        // Check for Dutch locale
70        else if locale.starts_with("nl_") {
71            ("Tijd".to_string(), "Inhoud".to_string())
72        }
73        // Check for Portuguese locale
74        else if locale.starts_with("pt_") {
75            ("Hora".to_string(), "Conteúdo".to_string())
76        }
77        // Check for Russian locale
78        else if locale.starts_with("ru_") {
79            ("Время".to_string(), "Содержание".to_string())
80        }
81        // Check for Japanese locale
82        else if locale.starts_with("ja_") {
83            ("時間".to_string(), "内容".to_string())
84        }
85        // Check for Chinese locale
86        else if locale.starts_with("zh_") {
87            ("时间".to_string(), "内容".to_string())
88        }
89        // Default to English
90        else {
91            ("Time".to_string(), "Content".to_string())
92        }
93    }
94
95    pub fn get_note_path(&self, date: NaiveDate) -> PathBuf {
96        if let Some(ref format) = self.config.file_path_format {
97            // Use custom file path format
98            let formatted_path = self.format_custom_path(format, date);
99            self.config.path.join(formatted_path)
100        } else {
101            // Use default format: YYYY-MM-DD.md
102            let date_str = self.date_handler.format_date(date);
103            self.config.path.join(format!("{}.md", date_str))
104        }
105    }
106
107    /// Backwards-compatible API: add a note without category
108    pub fn add_note(&self, content: &str, timestamp: Option<DateTime<Local>>) -> Result<(), JourneyError> {
109        self.add_note_with_category(content, timestamp, None)
110    }
111
112    /// Category-aware API used by the application
113    pub fn add_note_with_category(&self, content: &str, timestamp: Option<DateTime<Local>>, category: Option<&str>) -> Result<(), JourneyError> {
114        let timestamp = timestamp.unwrap_or_else(|| self.date_handler.get_current_datetime());
115        let date = timestamp.date_naive();
116        let note_path = self.get_note_path(date);
117
118        // Ensure the vault directory exists
119        if let Some(parent) = note_path.parent() {
120            fs::create_dir_all(parent)?;
121        }
122
123        // Expand phrases in the content
124        let expanded_content = self.expand_phrases(content);
125        let formatted_time = self.date_handler.format_datetime(timestamp);
126        
127        // Get the configured note format (default to bullet if not specified)
128        let note_format = self.config.list_type.as_ref().unwrap_or(&NoteFormat::Bullet);
129        let note_entry = self.format_note_entry(&formatted_time, &expanded_content, note_format);
130
131        // Check if file exists and has content
132        if note_path.exists() {
133            let existing_content = fs::read_to_string(&note_path)?;
134            
135            // Check if we need to convert the existing format
136            let converted_content = self.convert_note_format_if_needed(&existing_content, note_format)?;
137            
138            // If section_header is specified, find and insert into that section in chronological order
139            if let Some(section_name) = self.config.get_section_header(category) {
140                if let Some(section_start) = self.find_section(&converted_content, section_name) {
141                    let mut lines: Vec<&str> = converted_content.lines().collect();
142                    
143                    // Find insertion index to keep chronological order within the section
144                    let insertion_index = self.find_insertion_index(
145                        &lines,
146                        section_start,
147                        &formatted_time,
148                        self.config.list_type.as_ref().unwrap_or(&NoteFormat::Bullet)
149                    );
150                    
151                    // Insert the note at the chosen index
152                    lines.insert(insertion_index, &note_entry);
153                    
154                    fs::write(&note_path, lines.join("\n"))?;
155                    return Ok(());
156                } else {
157                    // Section doesn't exist, create it at the end
158                    let mut new_content = converted_content;
159                    if !new_content.ends_with('\n') {
160                        new_content.push('\n');
161                    }
162                    new_content.push_str(&format!("\n# {}\n", section_name));
163                    new_content.push_str(&note_entry);
164                    fs::write(&note_path, new_content)?;
165                    return Ok(());
166                }
167            }
168            
169            // No section configured: insert in chronological order across the whole file
170            let mut lines: Vec<&str> = converted_content.lines().collect();
171            let insertion_index = self.find_insertion_index(
172                &lines,
173                0,
174                &formatted_time,
175                self.config.list_type.as_ref().unwrap_or(&NoteFormat::Bullet)
176            );
177            lines.insert(insertion_index, &note_entry);
178            fs::write(&note_path, lines.join("\n"))?;
179        } else {
180            // Create new file
181            let file_content = if let Some(template_file) = &self.config.template_file {
182                // Use template file
183                self.create_file_from_template(template_file, timestamp, &note_entry)?
184            } else {
185                // Use default template
186                self.create_default_file_content(date, &note_entry, category)
187            };
188            
189            fs::write(&note_path, file_content)?;
190        }
191
192        Ok(())
193    }
194
195    fn create_default_file_content(&self, date: NaiveDate, note_entry: &str, category: Option<&str>) -> String {
196        let mut file_content = String::new();
197        
198        // Add frontmatter
199        file_content.push_str("---\n");
200        file_content.push_str(&format!("date: {}\n", self.date_handler.format_date(date)));
201        file_content.push_str("---\n\n");
202        
203        // Add section if specified
204        if let Some(section_name) = self.config.get_section_header(category) {
205            file_content.push_str(&format!("# {}\n\n", section_name));
206        }
207        
208        // Add table header if using table format
209        let note_format = self.config.list_type.as_ref().unwrap_or(&NoteFormat::Bullet);
210        if note_format == &NoteFormat::Table {
211            let (time_header, content_header) = self.get_table_headers();
212            file_content.push_str(&format!("| {} | {} |\n", time_header, content_header));
213            file_content.push_str("|------|----------|\n");
214        }
215        
216        file_content.push_str(note_entry);
217        file_content
218    }
219
220    /// Determine if a line is a table header or separator line
221    fn is_table_header_line(&self, line: &str) -> bool {
222        let trimmed = line.trim();
223
224        // Separator like |---|
225        if trimmed.starts_with("|---") {
226            return true;
227        }
228
229        // Match against localized/custom headers
230        if trimmed.starts_with('|') {
231            let (time_header, content_header) = self.get_table_headers();
232            let expected = format!("| {} | {} |", time_header, content_header);
233            if trimmed == expected {
234                return true;
235            }
236        }
237
238        false
239    }
240
241    fn create_file_from_template(&self, template_file: &str, timestamp: DateTime<Local>, note_entry: &str) -> Result<String, JourneyError> {
242        // Read the template file
243        let template_path = PathBuf::from(template_file);
244        let template_content = fs::read_to_string(&template_path)
245            .map_err(|e| JourneyError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, format!("Failed to read template file '{}': {}", template_file, e))))?;
246        
247        // Process template variables
248        let mut processed_content = template_content;
249        
250        // Replace template variables
251        let date = timestamp.date_naive();
252        processed_content = processed_content.replace("{{date}}", &self.date_handler.format_date(date));
253        processed_content = processed_content.replace("{{time}}", &self.date_handler.format_time(timestamp.time()));
254        processed_content = processed_content.replace("{{datetime}}", &self.date_handler.format_datetime(timestamp));
255        
256        // Additional template variables
257        // Yesterday and tomorrow dates - use same format as note filenames
258        let yesterday_date = date - chrono::Duration::days(1);
259        let tomorrow_date = date + chrono::Duration::days(1);
260        let yesterday = self.date_handler.format_date(yesterday_date);
261        let tomorrow = self.date_handler.format_date(tomorrow_date);
262        processed_content = processed_content.replace("{yesterday}", &yesterday);
263        processed_content = processed_content.replace("{tomorrow}", &tomorrow);
264        processed_content = processed_content.replace("{{yesterday}}", &yesterday);
265        processed_content = processed_content.replace("{{tomorrow}}", &tomorrow);
266        
267        // Weekday (lowercase) - full weekday name
268        let weekday = date.format("%A").to_string();
269        processed_content = processed_content.replace("{weekday}", &weekday);
270        processed_content = processed_content.replace("{{weekday}}", &weekday);
271        
272        // Weekday (uppercase) - abbreviated weekday name
273        let weekday_short = date.format("%a").to_string();
274        processed_content = processed_content.replace("{Weekday}", &weekday_short);
275        processed_content = processed_content.replace("{{Weekday}}", &weekday_short);
276        
277        // Additional variables
278        // Created timestamp
279        let created = timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
280        processed_content = processed_content.replace("{created}", &created);
281        processed_content = processed_content.replace("{{created}}", &created);
282        
283        // Today's date
284        let today = self.date_handler.format_date(date);
285        processed_content = processed_content.replace("{today}", &today);
286        processed_content = processed_content.replace("{{today}}", &today);
287        
288        // Handle section name replacement (support legacy and new placeholders)
289        if let Some(section_title) = self.config.section_header.as_ref().or(self.config.section_name.as_ref()) {
290            processed_content = processed_content.replace("{{section_header}}", section_title);
291            processed_content = processed_content.replace("{section_header}", section_title);
292            processed_content = processed_content.replace("{{section_name}}", section_title);
293            processed_content = processed_content.replace("{section_name}", section_title);
294        }
295        
296        // If the template doesn't contain a placeholder for notes, append the note
297        if !processed_content.contains("{{note}}") && !processed_content.contains("{note}") {
298            processed_content.push_str(note_entry);
299        } else {
300            processed_content = processed_content.replace("{{note}}", note_entry);
301            processed_content = processed_content.replace("{note}", note_entry);
302        }
303        
304        Ok(processed_content)
305    }
306
307    pub fn find_section(&self, content: &str, section_name: &str) -> Option<usize> {
308        let lines: Vec<&str> = content.lines().collect();
309        for (i, line) in lines.iter().enumerate() {
310            if line.trim().starts_with('#') && line.contains(section_name) {
311                return Some(i);
312            }
313        }
314        None
315    }
316
317    pub fn find_section_end(&self, lines: &[&str], section_start: usize) -> usize {
318        // Look for the next section header or end of file
319        for i in (section_start + 1)..lines.len() {
320            if lines[i].trim().starts_with('#') {
321                return i;
322            }
323        }
324        // If no next section found, return end of file
325        lines.len()
326    }
327
328    pub fn find_section_content_end(&self, lines: &[&str], section_start: usize) -> usize {
329        let section_end = self.find_section_end(lines, section_start);
330        
331        // Find the actual end of content in the section (skip blank lines at the end)
332        let mut content_end = section_start + 1;
333        for i in section_start + 1..section_end {
334            if !lines[i].trim().is_empty() {
335                content_end = i + 1;
336            }
337        }
338        
339        content_end
340    }
341
342    /// Backwards-compatible API: list notes without category
343    pub fn list_notes(&self, date: NaiveDate) -> Result<Vec<String>, JourneyError> {
344        self.list_notes_with_category(date, None)
345    }
346
347    /// Category-aware API used by the application
348    pub fn list_notes_with_category(&self, date: NaiveDate, category: Option<&str>) -> Result<Vec<String>, JourneyError> {
349        let note_path = self.get_note_path(date);
350        
351        if !note_path.exists() {
352            return Ok(vec![]);
353        }
354
355        let content = fs::read_to_string(&note_path)?;
356        let lines: Vec<&str> = content.lines().collect();
357        let mut notes = Vec::new();
358        
359        // If section_header is configured, only list notes within that section
360        if let Some(section_name) = self.config.get_section_header(category) {
361            if let Some(section_start) = self.find_section(&content, section_name) {
362                let section_end = self.find_section_end(&lines, section_start);
363                
364                // Only process lines within the section
365                for line in &lines[section_start..section_end] {
366                    let trimmed = line.trim();
367                    // Check for bullet format
368                    if trimmed.starts_with("- ") {
369                        notes.push(line.to_string());
370                    }
371                    // Check for table format (but not table headers or separators)
372                    else if trimmed.starts_with("|") && !trimmed.starts_with("|---") && trimmed.contains("|") {
373                        // Skip if it looks like a table header or separator
374                        if !self.is_table_header_line(trimmed) {
375                            notes.push(line.to_string());
376                        }
377                    }
378                }
379            }
380            // If section doesn't exist, return empty list
381        } else {
382            // No section configured, list all notes in the file
383            for line in &lines {
384                let trimmed = line.trim();
385                // Check for bullet format
386                if trimmed.starts_with("- ") {
387                    notes.push(line.to_string());
388                }
389                // Check for table format (but not table headers or separators)
390                else if trimmed.starts_with("|") && !trimmed.starts_with("|---") && trimmed.contains("|") {
391                    // Skip if it looks like a table header or separator
392                    if !self.is_table_header_line(trimmed) {
393                        notes.push(line.to_string());
394                    }
395                }
396            }
397        }
398
399        // Sort notes by time extracted from the line (ascending chronological)
400        let note_format = self.config.list_type.as_ref().unwrap_or(&NoteFormat::Bullet);
401        notes.sort_by(|a, b| {
402            let ta = self.extract_time_from_note_line(a, note_format);
403            let tb = self.extract_time_from_note_line(b, note_format);
404            
405            // If both have valid times, compare them
406            if let (Some(time_a), Some(time_b)) = (ta, tb) {
407                time_a.cmp(&time_b)
408            }
409            // If only one has a valid time, put the invalid one at the end
410            else if ta.is_some() {
411                std::cmp::Ordering::Less
412            }
413            else if tb.is_some() {
414                std::cmp::Ordering::Greater
415            }
416            // If neither has a valid time, maintain original order
417            else {
418                std::cmp::Ordering::Equal
419            }
420        });
421
422        Ok(notes)
423    }
424
425    pub fn get_editor_path(&self, date: NaiveDate) -> PathBuf {
426        self.get_note_path(date)
427    }
428
429    /// Expand phrases in the content using the vault's phrase mappings
430    fn expand_phrases(&self, content: &str) -> String {
431        let mut result = content.to_string();
432        
433        // Sort phrases by length (longest first) to avoid partial replacements
434        let mut phrases: Vec<_> = self.config.phrases.iter().collect();
435        phrases.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
436        
437        for (phrase, replacement) in phrases {
438            // Replace all occurrences of the phrase with its replacement
439            result = result.replace(phrase, replacement);
440        }
441        
442        result
443    }
444
445    /// Format a custom file path using date components
446    fn format_custom_path(&self, format: &str, date: NaiveDate) -> String {
447        let year = date.year();
448        let month = date.month();
449        let day = date.day();
450        let weekday = date.weekday();
451        
452        let mut result = format.to_string();
453        
454        // Replace year
455        result = result.replace("{year}", &year.to_string());
456        
457        // Replace month with zero-padding
458        result = result.replace("{month:02}", &format!("{:02}", month));
459        result = result.replace("{month}", &month.to_string());
460        
461        // Replace date:MM (zero-padded month) for Journals plugin compatibility
462        // Process double braces first to avoid conflicts
463        result = result.replace("{{date:MM}}", &format!("{:02}", month));
464        result = result.replace("{date:MM}", &format!("{:02}", month));
465        
466        // Replace date:y (two-digit year) for Journals plugin compatibility
467        // Process double braces first to avoid conflicts
468        result = result.replace("{{date:y}}", &format!("{:02}", year % 100));
469        result = result.replace("{date:y}", &format!("{:02}", year % 100));
470        
471        // Replace day/date with zero-padding (after date:MM and date:y to avoid conflicts)
472        result = result.replace("{day:02}", &format!("{:02}", day));
473        result = result.replace("{date:02}", &format!("{:02}", day));
474        result = result.replace("{day}", &day.to_string());
475        result = result.replace("{date}", &day.to_string());
476        
477        // Replace weekday names (case-sensitive)
478        result = result.replace("{Weekday}", &self.format_weekday(weekday, false));
479        result = result.replace("{weekday}", &self.format_weekday(weekday, false).to_lowercase());
480        result = result.replace("{Weekday_short}", &self.format_weekday(weekday, true));
481        result = result.replace("{weekday_short}", &self.format_weekday(weekday, true).to_lowercase());
482        
483        // Replace month names (case-sensitive)
484        result = result.replace("{Month}", &self.format_month(month, false));
485        result = result.replace("{month_name}", &self.format_month(month, false).to_lowercase());
486        result = result.replace("{Month_short}", &self.format_month(month, true));
487        result = result.replace("{month_short}", &self.format_month(month, true).to_lowercase());
488        
489        result
490    }
491    
492    /// Format weekday name (full or short)
493    fn format_weekday(&self, weekday: Weekday, short: bool) -> String {
494        match weekday {
495            Weekday::Mon => if short { "Mon".to_string() } else { "Monday".to_string() },
496            Weekday::Tue => if short { "Tue".to_string() } else { "Tuesday".to_string() },
497            Weekday::Wed => if short { "Wed".to_string() } else { "Wednesday".to_string() },
498            Weekday::Thu => if short { "Thu".to_string() } else { "Thursday".to_string() },
499            Weekday::Fri => if short { "Fri".to_string() } else { "Friday".to_string() },
500            Weekday::Sat => if short { "Sat".to_string() } else { "Saturday".to_string() },
501            Weekday::Sun => if short { "Sun".to_string() } else { "Sunday".to_string() },
502        }
503    }
504    
505    /// Format month name (full or short)
506    fn format_month(&self, month: u32, short: bool) -> String {
507        match month {
508            1 => if short { "Jan".to_string() } else { "January".to_string() },
509            2 => if short { "Feb".to_string() } else { "February".to_string() },
510            3 => if short { "Mar".to_string() } else { "March".to_string() },
511            4 => if short { "Apr".to_string() } else { "April".to_string() },
512            5 => if short { "May".to_string() } else { "May".to_string() },
513            6 => if short { "Jun".to_string() } else { "June".to_string() },
514            7 => if short { "Jul".to_string() } else { "July".to_string() },
515            8 => if short { "Aug".to_string() } else { "August".to_string() },
516            9 => if short { "Sep".to_string() } else { "September".to_string() },
517            10 => if short { "Oct".to_string() } else { "October".to_string() },
518            11 => if short { "Nov".to_string() } else { "November".to_string() },
519            12 => if short { "Dec".to_string() } else { "December".to_string() },
520            _ => "Unknown".to_string(),
521        }
522    }
523
524    /// Format a note entry according to the specified format
525    fn format_note_entry(&self, timestamp: &str, content: &str, format: &NoteFormat) -> String {
526        match format {
527            NoteFormat::Bullet => format!("- [{}] {}\n", timestamp, content),
528            NoteFormat::Table => format!("| {} | {} |\n", timestamp, content),
529        }
530    }
531
532    /// Extract NaiveTime from a note line according to format
533    fn extract_time_from_note_line(&self, line: &str, format: &NoteFormat) -> Option<chrono::NaiveTime> {
534        match format {
535            NoteFormat::Bullet => {
536                // "- [HH:MM[:SS]] content"
537                let trimmed = line.trim_start();
538                if let Some(rest) = trimmed.strip_prefix("- ") {
539                    if let Some(after_bracket) = rest.strip_prefix('[') {
540                        if let Some(closing) = after_bracket.find(']') {
541                            let time_str = &after_bracket[..closing];
542                            chrono::NaiveTime::parse_from_str(time_str, "%H:%M:%S")
543                                .or_else(|_| chrono::NaiveTime::parse_from_str(time_str, "%H:%M")).ok()
544                        } else { None }
545                    } else { None }
546                } else { None }
547            }
548            NoteFormat::Table => {
549                // "| HH:MM | content |"
550                let trimmed = line.trim();
551                if trimmed.starts_with('|') {
552                    let cols: Vec<&str> = trimmed.trim_matches('|').split('|').map(|s| s.trim()).collect();
553                    if let Some(time_str) = cols.get(0) {
554                        chrono::NaiveTime::parse_from_str(time_str, "%H:%M:%S")
555                            .or_else(|_| chrono::NaiveTime::parse_from_str(time_str, "%H:%M")).ok()
556                    } else { None }
557                } else { None }
558            }
559        }
560    }
561
562    /// Find insertion index within a section (or whole file when section_start=0) to keep chronological order
563    fn find_insertion_index(&self, lines: &[&str], section_start: usize, new_time_str: &str, format: &NoteFormat) -> usize {
564        let section_end = if section_start == 0 { lines.len() } else { self.find_section_end(lines, section_start) };
565        let new_time = chrono::NaiveTime::parse_from_str(new_time_str, "%H:%M:%S")
566            .or_else(|_| chrono::NaiveTime::parse_from_str(new_time_str, "%H:%M")).ok();
567
568        // Scan content lines and find first with time greater than new_time
569        let mut content_end = section_start + 1;
570        let mut insert_at = None;
571        for i in section_start + 1..section_end {
572            let trimmed = lines[i].trim();
573            if !trimmed.is_empty() { content_end = i + 1; }
574
575            let existing_time_opt = match format {
576                NoteFormat::Bullet => {
577                    if trimmed.starts_with("- ") {
578                        if let Some(rest) = trimmed.strip_prefix("- ") {
579                            if let Some(after_bracket) = rest.strip_prefix('[') {
580                                if let Some(closing) = after_bracket.find(']') {
581                                    let ts = &after_bracket[..closing];
582                                    chrono::NaiveTime::parse_from_str(ts, "%H:%M:%S")
583                                        .or_else(|_| chrono::NaiveTime::parse_from_str(ts, "%H:%M")).ok()
584                                } else { None }
585                            } else { None }
586                        } else { None }
587                    } else { None }
588                }
589                NoteFormat::Table => {
590                    if trimmed.starts_with('|') && !trimmed.starts_with("|---") {
591                        let cols: Vec<&str> = trimmed.trim_matches('|').split('|').map(|s| s.trim()).collect();
592                        cols.get(0).and_then(|ts| chrono::NaiveTime::parse_from_str(ts, "%H:%M:%S")
593                            .or_else(|_| chrono::NaiveTime::parse_from_str(ts, "%H:%M")).ok())
594                    } else { None }
595                }
596            };
597
598            if let (Some(new_t), Some(old_t)) = (new_time, existing_time_opt) {
599                if new_t < old_t {
600                    insert_at = Some(i);
601                    break;
602                }
603            }
604        }
605
606        insert_at.unwrap_or(content_end)
607    }
608
609    /// Detect the current note format in the content
610    pub fn detect_note_format(&self, content: &str) -> Option<NoteFormat> {
611        let lines: Vec<&str> = content.lines().collect();
612        
613        // Look for bullet format
614        let has_bullet_notes = lines.iter().any(|line| line.trim().starts_with("- "));
615        
616        // Look for table format
617        let has_table_notes = lines.iter().any(|line| {
618            let trimmed = line.trim();
619            trimmed.starts_with("|") && trimmed.contains("|") && !trimmed.starts_with("|---")
620        });
621        
622        if has_bullet_notes && !has_table_notes {
623            Some(NoteFormat::Bullet)
624        } else if has_table_notes && !has_bullet_notes {
625            Some(NoteFormat::Table)
626        } else {
627            None // Mixed or no notes found
628        }
629    }
630
631    /// Convert note format if needed
632    fn convert_note_format_if_needed(&self, content: &str, target_format: &NoteFormat) -> Result<String, JourneyError> {
633        let current_format = self.detect_note_format(content);
634        
635        // If no format detected or already matches target, clean up blank lines if it's table format
636        if current_format.is_none() || current_format.as_ref() == Some(target_format) {
637            if target_format == &NoteFormat::Table {
638                return Ok(self.clean_table_blank_lines(content));
639            }
640            return Ok(content.to_string());
641        }
642        
643        let lines: Vec<&str> = content.lines().collect();
644        let mut converted_lines = Vec::new();
645        let mut first_note_found = false;
646        
647        for line in lines {
648            let trimmed = line.trim();
649            
650            // Convert bullet to table
651            if target_format == &NoteFormat::Table && trimmed.starts_with("- ") {
652                // Add table header before the first note
653                if !first_note_found {
654                    let (time_header, content_header) = self.get_table_headers();
655                    converted_lines.push(format!("| {} | {} |", time_header, content_header));
656                    converted_lines.push("|------|----------|".to_string());
657                    first_note_found = true;
658                }
659                
660                if let Some((timestamp, note_content)) = self.parse_bullet_note(trimmed) {
661                    converted_lines.push(format!("| {} | {} |", timestamp, note_content));
662                } else {
663                    converted_lines.push(line.to_string());
664                }
665            }
666            // Skip blank lines when converting bullet to table
667            else if target_format == &NoteFormat::Table && trimmed.is_empty() && first_note_found {
668                // Skip blank lines between bullet points when converting to table
669                continue;
670            }
671            // Convert table to bullet
672            else if target_format == &NoteFormat::Bullet && trimmed.starts_with("|") && !trimmed.starts_with("|---") && !trimmed.contains("Time") && !trimmed.contains("Content") {
673                if let Some((timestamp, note_content)) = self.parse_table_note(trimmed) {
674                    converted_lines.push(format!("- [{}] {}", timestamp, note_content));
675                } else {
676                    converted_lines.push(line.to_string());
677                }
678            }
679            // Skip table headers and separators when converting to bullet
680            else if target_format == &NoteFormat::Bullet && trimmed.starts_with("|") && (trimmed.contains("Time") || trimmed.contains("Content") || trimmed.starts_with("|---")) {
681                // Skip table headers and separators
682                continue;
683            }
684            // Skip blank lines when converting table to bullet
685            else if target_format == &NoteFormat::Bullet && trimmed.is_empty() {
686                // Skip blank lines between table rows when converting to bullet
687                continue;
688            }
689            else {
690                converted_lines.push(line.to_string());
691            }
692        }
693        
694        Ok(converted_lines.join("\n"))
695    }
696
697    /// Clean up blank lines in table format
698    fn clean_table_blank_lines(&self, content: &str) -> String {
699        let lines: Vec<&str> = content.lines().collect();
700        let mut cleaned_lines = Vec::new();
701        let mut in_table = false;
702        let mut last_was_table_row = false;
703        
704        for line in lines {
705            let trimmed = line.trim();
706            
707            // Check if this is a table row
708            if trimmed.starts_with("|") && !trimmed.starts_with("|---") && !trimmed.contains("Time") && !trimmed.contains("Content") {
709                in_table = true;
710                last_was_table_row = true;
711                cleaned_lines.push(line.to_string());
712            }
713            // Check if this is a table header or separator
714            else if trimmed.starts_with("|") && (trimmed.contains("Time") || trimmed.contains("Content") || trimmed.starts_with("|---")) {
715                in_table = true;
716                last_was_table_row = false;
717                cleaned_lines.push(line.to_string());
718            }
719            // Check if this is a blank line
720            else if trimmed.is_empty() {
721                // Only keep blank lines if we're not in a table or if it's the first blank line after a table
722                if !in_table || !last_was_table_row {
723                    cleaned_lines.push(line.to_string());
724                }
725                last_was_table_row = false;
726            }
727            // Non-table content
728            else {
729                in_table = false;
730                last_was_table_row = false;
731                cleaned_lines.push(line.to_string());
732            }
733        }
734        
735        cleaned_lines.join("\n")
736    }
737
738    /// Parse a bullet note to extract timestamp and content
739    fn parse_bullet_note(&self, line: &str) -> Option<(String, String)> {
740        // Format: "- [timestamp] content"
741        if let Some(after_dash) = line.trim_start().strip_prefix("- ") {
742            if let Some(after_bracket) = after_dash.strip_prefix('[') {
743                if let Some(closing) = after_bracket.find(']') {
744                    let timestamp = after_bracket[..closing].to_string();
745                    let content = after_bracket[closing + 1..].trim().to_string();
746                    return Some((timestamp, content));
747                }
748            }
749        }
750        None
751    }
752
753    /// Parse a table note to extract timestamp and content
754    fn parse_table_note(&self, line: &str) -> Option<(String, String)> {
755        // Format: "| timestamp | content |"
756        let parts: Vec<&str> = line.split('|').collect();
757        if parts.len() >= 3 {
758            let timestamp = parts[1].trim().to_string();
759            let content = parts[2].trim().to_string();
760            return Some((timestamp, content));
761        }
762        None
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use std::fs;
770    use std::collections::HashMap;
771    use tempfile::TempDir;
772
773    fn create_test_vault() -> (Vault, TempDir) {
774        let temp_dir = TempDir::new().unwrap();
775        let vault_path = temp_dir.path().join("test_vault");
776        fs::create_dir_all(&vault_path).unwrap();
777
778        let config = VaultConfig {
779            name: "test".to_string(),
780            path: vault_path,
781            locale: "en_US.UTF-8".to_string(),
782            phrases: HashMap::new(),
783            section_header: None,
784            section_header_work: None,
785            section_header_personal: None,
786            section_header_health: None,
787            section_header_meetings: None,
788            table_headers: None,
789            date_format: None,
790            template_file: None,
791            file_path_format: None,
792            list_type: Some(NoteFormat::Table),
793            section_name: None,
794            weekly_format: None,
795            monthly_format: None,
796            quarterly_format: None,
797            yearly_format: None,
798            note_format: None,
799        };
800
801        let vault = Vault::new(config);
802        (vault, temp_dir)
803    }
804
805    #[test]
806    fn test_table_format_no_blank_lines() {
807        let (vault, _temp_dir) = create_test_vault();
808        let date = chrono::Local::now().date_naive();
809
810        // Add first note
811        vault.add_note("Test1", None).unwrap();
812
813        // Add second note
814        vault.add_note("Test2", None).unwrap();
815
816        // Read the file content
817        let note_path = vault.get_note_path(date);
818        let content = fs::read_to_string(&note_path).unwrap();
819        let lines: Vec<&str> = content.lines().collect();
820        
821
822        // Find the table section
823        let mut table_start = None;
824        for (i, line) in lines.iter().enumerate() {
825            if line.starts_with("| Time | Content |") {
826                table_start = Some(i);
827                break;
828            }
829        }
830
831        assert!(table_start.is_some(), "Table header not found");
832        let start = table_start.unwrap();
833
834        // Check that there are no blank lines between table rows
835        let table_lines = &lines[start..];
836        
837        // Find the first data row (after header and separator)
838        let mut data_start = None;
839        for (i, line) in table_lines.iter().enumerate() {
840            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
841                data_start = Some(i);
842                break;
843            }
844        }
845
846        assert!(data_start.is_some(), "No data rows found in table");
847        let data_start = data_start.unwrap();
848
849        // Check that there are no blank lines between data rows
850        let mut in_data_section = false;
851        let mut blank_line_found = false;
852        
853        for line in &table_lines[data_start..] {
854            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
855                if in_data_section && blank_line_found {
856                    panic!("Blank line found between table rows");
857                }
858                in_data_section = true;
859                blank_line_found = false;
860            } else if line.trim().is_empty() {
861                if in_data_section {
862                    blank_line_found = true;
863                }
864            }
865        }
866    }
867
868    #[test]
869    fn test_table_format_existing_file_no_blank_lines() {
870        let (vault, _temp_dir) = create_test_vault();
871        let date = chrono::Local::now().date_naive();
872
873        // Create a file with some existing content first that has blank lines
874        let note_path = vault.get_note_path(date);
875        let existing_content = "---
876date: 2025-10-26
877---
878
879| Time | Content |
880|------|----------|
881| 00:04:43 | Test1 |
882
883| 00:05:28 | Test2 |
884";
885        fs::write(&note_path, existing_content).unwrap();
886
887        // Add third note to existing file
888        vault.add_note("Test3", None).unwrap();
889
890        // Add fourth note to existing file
891        vault.add_note("Test4", None).unwrap();
892
893        // Read the file content
894        let note_path = vault.get_note_path(date);
895        let content = fs::read_to_string(&note_path).unwrap();
896        let lines: Vec<&str> = content.lines().collect();
897        
898
899        // Find the table section
900        let mut table_start = None;
901        for (i, line) in lines.iter().enumerate() {
902            if line.starts_with("| Time | Content |") {
903                table_start = Some(i);
904                break;
905            }
906        }
907
908        assert!(table_start.is_some(), "Table header not found");
909        let start = table_start.unwrap();
910
911        // Check that there are no blank lines between table rows
912        let table_lines = &lines[start..];
913        
914        // Find the first data row (after header and separator)
915        let mut data_start = None;
916        for (i, line) in table_lines.iter().enumerate() {
917            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
918                data_start = Some(i);
919                break;
920            }
921        }
922
923        assert!(data_start.is_some(), "No data rows found in table");
924        let data_start = data_start.unwrap();
925
926        // Check that there are no blank lines between data rows
927        let mut in_data_section = false;
928        let mut blank_line_found = false;
929        
930        for line in &table_lines[data_start..] {
931            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
932                if in_data_section && blank_line_found {
933                    panic!("Blank line found between table rows in existing file");
934                }
935                in_data_section = true;
936                blank_line_found = false;
937            } else if line.trim().is_empty() {
938                if in_data_section {
939                    blank_line_found = true;
940                }
941            }
942        }
943    }
944
945    #[test]
946    fn test_bullet_format_no_header() {
947        let (mut vault, _temp_dir) = create_test_vault();
948        
949        // Set bullet format
950        vault.config.list_type = Some(NoteFormat::Bullet);
951        
952        let date = chrono::Local::now().date_naive();
953
954        // Add first note - should not create a header
955        vault.add_note("Test1", None).unwrap();
956
957        // Read the file content
958        let note_path = vault.get_note_path(date);
959        let content = fs::read_to_string(&note_path).unwrap();
960        
961        
962        // Should not contain table headers
963        assert!(!content.contains("| Time | Content |"), "Bullet format should not have table headers");
964        assert!(!content.contains("|------|----------|"), "Bullet format should not have table separators");
965        
966        // Should contain bullet format
967        assert!(content.contains("- "), "Bullet format should contain bullet points");
968    }
969
970    #[test]
971    fn test_table_to_bullet_conversion_removes_header() {
972        let (mut vault, _temp_dir) = create_test_vault();
973        
974        // Start with table format
975        vault.config.list_type = Some(NoteFormat::Table);
976        
977        let date = chrono::Local::now().date_naive();
978
979        // Add first note to create table format
980        vault.add_note("Test1", None).unwrap();
981
982        // Switch to bullet format
983        vault.config.list_type = Some(NoteFormat::Bullet);
984
985        // Add second note - should convert to bullet format without headers
986        vault.add_note("Test2", None).unwrap();
987
988        // Read the file content
989        let note_path = vault.get_note_path(date);
990        let content = fs::read_to_string(&note_path).unwrap();
991        
992        
993        // Should not contain table headers
994        assert!(!content.contains("| Time | Content |"), "Converted bullet format should not have table headers");
995        assert!(!content.contains("|------|----------|"), "Converted bullet format should not have table separators");
996        
997        // Should contain bullet format
998        assert!(content.contains("- "), "Converted bullet format should contain bullet points");
999    }
1000
1001    #[test]
1002    fn test_bullet_to_table_conversion_no_blank_lines() {
1003        let (mut vault, _temp_dir) = create_test_vault();
1004        
1005        // Start with bullet format
1006        vault.config.list_type = Some(NoteFormat::Bullet);
1007        
1008        let date = chrono::Local::now().date_naive();
1009
1010        // Create a file with bullet points that have blank lines between them
1011        let note_path = vault.get_note_path(date);
1012        let existing_content = "---
1013date: 2025-10-26
1014---
1015
1016- 00:04:43 Test1
1017
1018- 00:05:28 Test2
1019";
1020        fs::write(&note_path, existing_content).unwrap();
1021
1022        // Switch to table format
1023        vault.config.list_type = Some(NoteFormat::Table);
1024
1025        // Add third note - should convert to table format without blank lines
1026        vault.add_note("Test3", None).unwrap();
1027
1028        // Read the file content
1029        let note_path = vault.get_note_path(date);
1030        let content = fs::read_to_string(&note_path).unwrap();
1031        
1032        
1033        // Should not contain blank lines between table rows
1034        let lines: Vec<&str> = content.lines().collect();
1035        let mut in_table = false;
1036        let mut blank_line_found = false;
1037        
1038        for line in &lines {
1039            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
1040                if in_table && blank_line_found {
1041                    panic!("Blank line found between table rows in bullet-to-table conversion");
1042                }
1043                in_table = true;
1044                blank_line_found = false;
1045            } else if line.trim().is_empty() {
1046                if in_table {
1047                    blank_line_found = true;
1048                }
1049            }
1050        }
1051    }
1052
1053    #[test]
1054    fn test_table_to_bullet_conversion_no_blank_lines() {
1055        let (mut vault, _temp_dir) = create_test_vault();
1056        
1057        // Start with table format
1058        vault.config.list_type = Some(NoteFormat::Table);
1059        
1060        let date = chrono::Local::now().date_naive();
1061
1062        // Create a file with table rows that have blank lines between them
1063        let note_path = vault.get_note_path(date);
1064        let existing_content = "---
1065date: 2025-10-26
1066---
1067
1068| Time | Content |
1069|------|----------|
1070| 00:04:43 | Test1 |
1071
1072| 00:05:28 | Test2 |
1073";
1074        fs::write(&note_path, existing_content).unwrap();
1075
1076        // Switch to bullet format
1077        vault.config.list_type = Some(NoteFormat::Bullet);
1078
1079        // Add third note - should convert to bullet format without blank lines
1080        vault.add_note("Test3", None).unwrap();
1081
1082        // Read the file content
1083        let note_path = vault.get_note_path(date);
1084        let content = fs::read_to_string(&note_path).unwrap();
1085        
1086        
1087        // Should not contain blank lines between bullet points
1088        let lines: Vec<&str> = content.lines().collect();
1089        let mut in_bullet_section = false;
1090        let mut blank_line_found = false;
1091        
1092        for line in &lines {
1093            if line.starts_with("- ") {
1094                if in_bullet_section && blank_line_found {
1095                    panic!("Blank line found between bullet points in table-to-bullet conversion");
1096                }
1097                in_bullet_section = true;
1098                blank_line_found = false;
1099            } else if line.trim().is_empty() {
1100                if in_bullet_section {
1101                    blank_line_found = true;
1102                }
1103            }
1104        }
1105    }
1106
1107    #[test]
1108    fn test_adding_note_to_existing_file_no_blank_lines() {
1109        let (vault, _temp_dir) = create_test_vault();
1110        let date = chrono::Local::now().date_naive();
1111
1112        // Create a file with some existing content that has blank lines between table rows
1113        let note_path = vault.get_note_path(date);
1114        let existing_content = "---
1115date: 2025-10-26
1116---
1117
1118| Time | Content |
1119|------|----------|
1120| 00:04:43 | Test1 |
1121
1122| 00:05:28 | Test2 |
1123";
1124        fs::write(&note_path, existing_content).unwrap();
1125
1126        // Add third note to existing file - should not create blank lines
1127        vault.add_note("Test3", None).unwrap();
1128
1129        // Add fourth note to existing file - should not create blank lines
1130        vault.add_note("Test4", None).unwrap();
1131
1132        // Read the file content
1133        let note_path = vault.get_note_path(date);
1134        let content = fs::read_to_string(&note_path).unwrap();
1135        
1136        // Debug: print the actual content
1137        println!("Adding notes to existing file content:");
1138        println!("{}", content);
1139        
1140        // Should not contain blank lines between notes
1141        let lines: Vec<&str> = content.lines().collect();
1142        let mut in_notes_section = false;
1143        let mut blank_line_found = false;
1144        
1145        for line in &lines {
1146            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
1147                if in_notes_section && blank_line_found {
1148                    panic!("Blank line found between notes when adding to existing file");
1149                }
1150                in_notes_section = true;
1151                blank_line_found = false;
1152            } else if line.trim().is_empty() {
1153                if in_notes_section {
1154                    blank_line_found = true;
1155                }
1156            }
1157        }
1158    }
1159
1160    #[test]
1161    fn test_append_note_without_conversion_no_blank_lines() {
1162        let (vault, _temp_dir) = create_test_vault();
1163        let date = chrono::Local::now().date_naive();
1164
1165        // Create a file with some existing content
1166        let note_path = vault.get_note_path(date);
1167        let existing_content = "---
1168date: 2025-10-26
1169---
1170
1171| Time | Content |
1172|------|----------|
1173| 00:04:43 | Test1 |
1174| 00:05:28 | Test2 |
1175";
1176        fs::write(&note_path, existing_content).unwrap();
1177
1178        // Add third note to existing file - this should trigger the append logic
1179        vault.add_note("Test3", None).unwrap();
1180
1181        // Read the file content
1182        let note_path = vault.get_note_path(date);
1183        let content = fs::read_to_string(&note_path).unwrap();
1184        
1185        
1186        // Should not contain blank lines between notes
1187        let lines: Vec<&str> = content.lines().collect();
1188        let mut in_notes_section = false;
1189        let mut blank_line_found = false;
1190        
1191        for line in &lines {
1192            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
1193                if in_notes_section && blank_line_found {
1194                    panic!("Blank line found between notes when appending to existing file");
1195                }
1196                in_notes_section = true;
1197                blank_line_found = false;
1198            } else if line.trim().is_empty() {
1199                if in_notes_section {
1200                    blank_line_found = true;
1201                }
1202            }
1203        }
1204    }
1205
1206    #[test]
1207    fn test_section_insertion_no_blank_lines() {
1208        let (mut vault, _temp_dir) = create_test_vault();
1209        
1210        // Set up a section header
1211        vault.config.section_header = Some("Test Section".to_string());
1212        
1213        let date = chrono::Local::now().date_naive();
1214
1215        // Create a file with a section that has some content
1216        let note_path = vault.get_note_path(date);
1217        let existing_content = "---
1218date: 2025-10-26
1219---
1220
1221# Test Section
1222
1223| Time | Content |
1224|------|----------|
1225| 00:04:43 | Test1 |
1226| 00:05:28 | Test2 |
1227
1228# Another Section
1229Some other content
1230";
1231        fs::write(&note_path, existing_content).unwrap();
1232
1233        // Add third note to the section - should not create blank lines
1234        vault.add_note("Test3", None).unwrap();
1235
1236        // Read the file content
1237        let note_path = vault.get_note_path(date);
1238        let content = fs::read_to_string(&note_path).unwrap();
1239        
1240        // Debug: print the actual content
1241        println!("Section insertion content:");
1242        println!("{}", content);
1243        
1244        // Should not contain blank lines between notes in the section
1245        let lines: Vec<&str> = content.lines().collect();
1246        let mut in_section = false;
1247        let mut blank_line_found = false;
1248        
1249        for line in &lines {
1250            if *line == "# Test Section" {
1251                in_section = true;
1252                blank_line_found = false;
1253                continue;
1254            }
1255            if *line == "# Another Section" {
1256                in_section = false;
1257                break;
1258            }
1259            if in_section && line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
1260                if blank_line_found {
1261                    panic!("Blank line found between notes in section");
1262                }
1263                blank_line_found = false;
1264            } else if in_section && (line.starts_with("|---") || line.contains("Time") || line.contains("Content")) {
1265                // Reset on table header/separator lines
1266                blank_line_found = false;
1267            } else if in_section && line.trim().is_empty() {
1268                blank_line_found = true;
1269            }
1270        }
1271    }
1272
1273    #[test]
1274    fn test_header_flag_functionality() {
1275        let (vault, _temp_dir) = create_test_vault();
1276        let date = chrono::Local::now().date_naive();
1277
1278        // Add some notes in table format
1279        vault.add_note("Test1", None).unwrap();
1280        vault.add_note("Test2", None).unwrap();
1281
1282        // Test listing without header flag
1283        let notes = vault.list_notes(date).unwrap();
1284        assert!(!notes.is_empty(), "Should have notes");
1285        
1286        // Verify that table headers are not included in the notes
1287        for note in &notes {
1288            assert!(!note.contains("Time") && !note.contains("Content"), 
1289                "Table headers should not be included in notes: {}", note);
1290        }
1291    }
1292
1293    #[test]
1294    fn test_table_format_with_section_no_blank_lines() {
1295        let (mut vault, _temp_dir) = create_test_vault();
1296        
1297        // Set up a section header
1298        vault.config.section_header = Some("Test Section".to_string());
1299        
1300        let date = chrono::Local::now().date_naive();
1301
1302        // Add first note
1303        vault.add_note("Test1", None).unwrap();
1304
1305        // Add second note
1306        vault.add_note("Test2", None).unwrap();
1307
1308        // Read the file content
1309        let note_path = vault.get_note_path(date);
1310        let content = fs::read_to_string(&note_path).unwrap();
1311        let lines: Vec<&str> = content.lines().collect();
1312
1313        // Find the section
1314        let mut section_start = None;
1315        for (i, line) in lines.iter().enumerate() {
1316            if *line == "# Test Section" {
1317                section_start = Some(i);
1318                break;
1319            }
1320        }
1321
1322        assert!(section_start.is_some(), "Section header not found");
1323        let start = section_start.unwrap();
1324
1325        // Check that there are no blank lines between the section header and table
1326        let section_lines = &lines[start..];
1327        
1328        // The line after the section header should be the table header
1329        assert!(section_lines.len() > 1, "No content after section header");
1330        
1331        // Find the table header after the section
1332        let mut table_header_found = false;
1333        for line in section_lines {
1334            if line.starts_with("| Time | Content |") {
1335                table_header_found = true;
1336                break;
1337            }
1338        }
1339        assert!(table_header_found, "Table header not found after section");
1340
1341        // Check for blank lines between table rows
1342        let mut in_table = false;
1343        let mut blank_line_found = false;
1344        
1345        for line in section_lines {
1346            if line.starts_with("|") && !line.starts_with("|---") && !line.contains("Time") && !line.contains("Content") {
1347                if in_table && blank_line_found {
1348                    panic!("Blank line found between table rows in section");
1349                }
1350                in_table = true;
1351                blank_line_found = false;
1352            } else if line.trim().is_empty() {
1353                if in_table {
1354                    blank_line_found = true;
1355                }
1356            }
1357        }
1358    }
1359}
1360