use std::fs; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use crate::types::VersionEntry; /// Directory: .helixnotes/history// fn history_dir(vault_path: &str, note_id: &str) -> PathBuf { Path::new(vault_path) .join(".helixnotes") .join("history") .join(note_id) } /// Save a version snapshot if enough time has passed since the last one. /// Minimum interval: 5 minutes between snapshots. pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) { let dir = history_dir(vault_path, note_id); // Check if we should create a snapshot (5 min cooldown) if let Ok(entries) = fs::read_dir(&dir) { let mut files: Vec = entries .filter_map(|e| e.ok()) .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) .filter(|n| n.ends_with(".md")) .collect(); files.sort(); if let Some(last) = files.last() { if let Some(ts) = last.strip_suffix(".md") { // Parse timestamp: 2026-02-08T18-30-00.md if let Some(t_pos) = ts.find('T') { let date_part = &ts[..t_pos]; let time_part = &ts[t_pos + 1..]; let time_colons = time_part.replace('-', ":"); let iso = format!("{}T{}Z", date_part, time_colons); if let Ok(last_time) = iso.parse::>() { let elapsed = Utc::now() - last_time; if elapsed.num_minutes() < 5 { return; // Too soon, skip } } } } } } // Create snapshot if let Err(e) = fs::create_dir_all(&dir) { eprintln!("Failed to create history dir: {}", e); return; } let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string(); let filename = format!("{}.md", timestamp); let path = dir.join(&filename); if let Err(e) = fs::write(&path, raw_content) { eprintln!("Failed to write version snapshot: {}", e); return; } // Prune old versions if max_versions > 0 { let _ = prune_versions(&dir, max_versions); } } /// Force-create a version snapshot, bypassing the cooldown. pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) { let dir = history_dir(vault_path, note_id); if let Err(e) = fs::create_dir_all(&dir) { eprintln!("Failed to create history dir: {}", e); return; } let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string(); let filename = format!("{}.md", timestamp); let path = dir.join(&filename); if let Err(e) = fs::write(&path, raw_content) { eprintln!("Failed to write version snapshot: {}", e); return; } if max_versions > 0 { let _ = prune_versions(&dir, max_versions); } } /// List all version snapshots for a note, newest first. pub fn list_versions(vault_path: &str, note_id: &str) -> Result, String> { let dir = history_dir(vault_path, note_id); if !dir.exists() { return Ok(Vec::new()); } let mut entries: Vec = Vec::new(); for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if path.extension().map_or(false, |ext| ext == "md") { let filename = path .file_stem() .unwrap_or_default() .to_string_lossy() .to_string(); let meta = fs::metadata(&path).map_err(|e| e.to_string())?; // Parse timestamp from filename let timestamp = if let Some(t_pos) = filename.find('T') { let date_part = &filename[..t_pos]; let time_part = &filename[t_pos + 1..]; let time_colons = time_part.replace('-', ":"); format!("{}T{}Z", date_part, time_colons) } else { filename.clone() }; entries.push(VersionEntry { timestamp, size: meta.len(), }); } } // Newest first entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); Ok(entries) } /// Get the raw content of a specific version. pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result { // Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md let filename = if let Some(t_pos) = timestamp.find('T') { let date_part = ×tamp[..t_pos]; let time_part = timestamp[t_pos + 1..].trim_end_matches('Z'); let time_dashes = time_part.replace(':', "-"); format!("{}.md", format!("{}T{}", date_part, time_dashes)) } else { format!("{}.md", timestamp) }; let path = history_dir(vault_path, note_id).join(&filename); fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e)) } /// Remove old versions keeping only the newest `max` entries. fn prune_versions(dir: &Path, max: u32) -> Result<(), String> { let mut files: Vec = fs::read_dir(dir) .map_err(|e| e.to_string())? .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.extension().map_or(false, |ext| ext == "md")) .collect(); // Sort by name (timestamps sort lexicographically) — newest last files.sort(); if files.len() as u32 > max { let to_remove = files.len() as u32 - max; for path in files.iter().take(to_remove as usize) { let _ = fs::remove_file(path); } } Ok(()) }