Files
HelixNotes/src-tauri/src/history.rs
T

172 lines
5.7 KiB
Rust

use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::types::VersionEntry;
/// Directory: .helixnotes/history/<note-id>/
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<String> = 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::<DateTime<Utc>>() {
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<Vec<VersionEntry>, String> {
let dir = history_dir(vault_path, note_id);
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<VersionEntry> = 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<String, String> {
// 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 = &timestamp[..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<PathBuf> = 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(())
}