//! Automated backup scheduler. //! //! Runs in the background and creates compressed backups based on user settings. //! Handles backup retention by pruning old backups when max count is exceeded. use crate::export::backup::{write_backup, FullExport}; use crate::state::{AppState, DESKTOP_USER_ID}; use chrono::Utc; use std::sync::Arc; use tauri::Manager; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; /// Check interval for automated backups (1 minute) const CHECK_INTERVAL_SECS: u64 = 60; /// Starts the background backup scheduler that creates automatic backups /// based on user settings and prunes old backups. pub async fn start_backup_scheduler(app: tauri::AppHandle, cancel: CancellationToken) { info!( "Starting backup scheduler (check interval: {}s)", CHECK_INTERVAL_SECS ); let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)); // Skip the first immediate tick interval.tick().await; loop { tokio::select! { _ = cancel.cancelled() => { info!("Backup scheduler shutting down"); break; } _ = interval.tick() => {} } // Get app state let state = match app.try_state::>() { Some(s) => s, None => { debug!("App state not available, skipping backup check"); continue; } }; // Check if backup is needed and perform it if let Err(e) = check_and_backup(&app, &state).await { error!(error = %e, "Error in backup scheduler"); } } } /// Checks if a backup is needed based on settings and performs it if necessary. async fn check_and_backup(app: &tauri::AppHandle, state: &Arc) -> Result<(), String> { // Get backup settings (create defaults if not set) let settings = match state.backup_settings.get(DESKTOP_USER_ID).await { Ok(Some(s)) => s, Ok(None) => { // Create default settings let defaults = goingson_core::NewBackupSettings { auto_backup_enabled: true, backup_frequency_minutes: 15, max_backups_to_keep: 1, }; state .backup_settings .upsert(DESKTOP_USER_ID, defaults) .await .map_err(|e| e.to_string())? } Err(e) => return Err(format!("Failed to get backup settings: {}", e)), }; // Check if auto backup is enabled if !settings.auto_backup_enabled { debug!("Auto backup is disabled"); return Ok(()); } // Check if enough time has passed since last backup let now = Utc::now(); let should_backup = match settings.last_backup_at { Some(last) => { let minutes_since = (now - last).num_minutes(); minutes_since >= settings.backup_frequency_minutes as i64 } None => true, // Never backed up, do it now }; if !should_backup { debug!("Backup not needed yet"); return Ok(()); } info!("Starting automated backup"); // Perform the backup let backup_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))? .join("backups"); std::fs::create_dir_all(&backup_dir) .map_err(|e| format!("Failed to create backup directory: {}", e))?; // Generate timestamped filename let timestamp = now.format("%Y%m%d-%H%M%S"); let filename = format!("goingson-backup-{}.json.gz", timestamp); let file_path = backup_dir.join(&filename); // Fetch all data let projects = state .projects .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let tasks = state .tasks .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let events = state .events .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let emails = state .emails .list_all(DESKTOP_USER_ID, true) .await .map_err(|e| e.to_string())?; let contacts = state .contacts .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let export = FullExport::new(projects, tasks, events, emails, contacts); let size = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; info!( path = %file_path.display(), size_bytes = size, items = export.total_count(), "Automated backup completed" ); // Update last backup timestamp state .backup_settings .update_last_backup_at(DESKTOP_USER_ID, now) .await .map_err(|e| format!("Failed to update last backup time: {}", e))?; // Prune old backups prune_old_backups(&backup_dir, settings.max_backups_to_keep as usize)?; Ok(()) } /// Removes old backups to maintain the maximum count. fn prune_old_backups(backup_dir: &std::path::Path, max_to_keep: usize) -> Result<(), String> { if max_to_keep == 0 { return Ok(()); // Keep all backups } let mut backups: Vec<_> = std::fs::read_dir(backup_dir) .map_err(|e| format!("Failed to read backup directory: {}", e))? .filter_map(|entry| entry.ok()) .filter(|entry| { entry .path() .extension() .map(|ext| ext == "gz") .unwrap_or(false) }) .filter_map(|entry| { entry .metadata() .ok() .and_then(|m| m.created().ok()) .map(|created| (entry.path(), created)) }) .collect(); // Sort by creation time, newest first backups.sort_by(|a, b| b.1.cmp(&a.1)); // Remove backups beyond the limit for (path, _) in backups.into_iter().skip(max_to_keep) { info!(path = %path.display(), "Pruning old backup"); if let Err(e) = std::fs::remove_file(&path) { warn!(path = %path.display(), error = %e, "Failed to remove old backup"); } } Ok(()) } /// Performs an immediate backup (for manual trigger or on-demand). pub async fn create_backup_now( app: &tauri::AppHandle, state: &Arc, ) -> Result { let now = Utc::now(); let backup_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))? .join("backups"); std::fs::create_dir_all(&backup_dir) .map_err(|e| format!("Failed to create backup directory: {}", e))?; let timestamp = now.format("%Y%m%d-%H%M%S"); let filename = format!("goingson-backup-{}.json.gz", timestamp); let file_path = backup_dir.join(&filename); // Fetch all data let projects = state .projects .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let tasks = state .tasks .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let events = state .events .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let emails = state .emails .list_all(DESKTOP_USER_ID, true) .await .map_err(|e| e.to_string())?; let contacts = state .contacts .list_all(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; let export = FullExport::new(projects, tasks, events, emails, contacts); let item_count = export.total_count(); let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; // Update last backup timestamp state .backup_settings .update_last_backup_at(DESKTOP_USER_ID, now) .await .map_err(|e| format!("Failed to update last backup time: {}", e))?; // Prune old backups if settings exist if let Ok(Some(settings)) = state.backup_settings.get(DESKTOP_USER_ID).await { let _ = prune_old_backups(&backup_dir, settings.max_backups_to_keep as usize); } Ok(crate::commands::ExportResponse { file_path: file_path.to_string_lossy().into_owned(), item_count, size_bytes, }) }