//! Export and backup commands. //! //! Provides data export functionality in multiple formats: //! - JSON: Full export of all data //! - CSV: Task export for spreadsheet applications //! - ICS: Calendar event export for calendar applications //! - Backup: Compressed JSON with restore capability use std::path::Path; use std::sync::Arc; use chrono::Utc; use serde::{Deserialize, Serialize}; use tauri::{Manager, State}; use tracing::instrument; use goingson_core::ProjectId; use crate::export::{backup, csv, ics}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, ResultApiError}; // ============ Path Validation ============ /// Validates that a user-supplied export/restore path does not contain `..` components. /// /// This is defense-in-depth for a desktop app that uses file picker dialogs -- /// the risk is low, but rejecting path traversal components costs nothing. pub(crate) fn validate_export_path(file_path: &str) -> Result<(), ApiError> { let path = Path::new(file_path); for component in path.components() { if matches!(component, std::path::Component::ParentDir) { return Err(ApiError::bad_request( "File path must not contain '..' components", )); } } Ok(()) } // ============ Response Types ============ /// Result of an export operation. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExportResponse { /// Path to the exported file. pub file_path: String, /// Number of items exported. pub item_count: usize, /// Size of the exported file in bytes. pub size_bytes: u64, } /// Result of a restore operation. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct RestoreResponse { /// Number of projects restored. pub projects_restored: usize, /// Number of tasks restored. pub tasks_restored: usize, /// Number of events restored. pub events_restored: usize, /// Number of emails restored. pub emails_restored: usize, /// Number of contacts restored. pub contacts_restored: usize, /// When the backup was originally created. pub backup_created_at: String, } /// Summary of available data for export. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExportSummaryResponse { /// Number of projects. pub project_count: usize, /// Number of tasks. pub task_count: usize, /// Number of events. pub event_count: usize, /// Number of emails. pub email_count: usize, /// Number of contacts. pub contact_count: usize, } // ============ Input Types ============ /// Options for restore operation. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RestoreOptions { /// If true, clear existing data before restore. /// If false, merge with existing data (may create duplicates). pub replace_all: bool, } // ============ Commands ============ /// Gets a summary of data available for export. /// /// Useful for showing the user what will be exported before they commit. #[tauri::command] #[instrument(skip_all)] pub async fn get_export_summary(state: State<'_, Arc>) -> Result { let (projects, tasks, events, emails, contacts) = tokio::join!( state.projects.list_all(DESKTOP_USER_ID), state.tasks.list_all(DESKTOP_USER_ID), state.events.list_all(DESKTOP_USER_ID), state.emails.list_all(DESKTOP_USER_ID, true), state.contacts.list_all(DESKTOP_USER_ID), ); Ok(ExportSummaryResponse { project_count: projects?.len(), task_count: tasks?.len(), event_count: events?.len(), email_count: emails?.len(), contact_count: contacts?.len(), }) } /// Exports all data as JSON. /// /// Creates a human-readable JSON file containing all projects, tasks, events, and emails. /// This format is best for manual inspection or migration to other systems. /// /// # Arguments /// /// * `file_path` - Destination path for the JSON file #[tauri::command] #[instrument(skip_all)] pub async fn export_json( state: State<'_, Arc>, file_path: String, ) -> Result { validate_export_path(&file_path)?; // Fetch all data let projects = state.projects.list_all(DESKTOP_USER_ID).await?; let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; let events = state.events.list_all(DESKTOP_USER_ID).await?; let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?; let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?; let export = backup::FullExport::new(projects, tasks, events, emails, contacts); let item_count = export.total_count(); let size_bytes = backup::write_json(&export, &file_path) .map_api_err("Failed to write JSON export", ApiError::internal)?; Ok(ExportResponse { file_path, item_count, size_bytes, }) } /// Exports tasks as CSV. /// /// Creates a spreadsheet-compatible CSV file. Optionally filter by project. /// /// # Arguments /// /// * `file_path` - Destination path for the CSV file /// * `project_id` - Optional project ID to filter tasks #[tauri::command] #[instrument(skip_all)] pub async fn export_tasks_csv( state: State<'_, Arc>, file_path: String, project_id: Option, ) -> Result { validate_export_path(&file_path)?; // Fetch tasks (filtered or all) let tasks = if let Some(pid) = project_id { state.tasks.list_by_project(DESKTOP_USER_ID, pid).await? } else { state.tasks.list_all(DESKTOP_USER_ID).await? }; // Fetch projects for name lookup let projects = state.projects.list_all(DESKTOP_USER_ID).await?; // Write CSV let file = std::fs::File::create(&file_path) .map_api_err("Failed to create CSV file", ApiError::internal)?; let item_count = csv::write_tasks_csv(&tasks, &projects, file) .map_api_err("Failed to write CSV", ApiError::internal)?; let size_bytes = std::fs::metadata(&file_path) .map(|m| m.len()) .unwrap_or(0); Ok(ExportResponse { file_path, item_count, size_bytes, }) } /// Exports events as ICS (iCalendar). /// /// Creates a calendar file that can be imported into Apple Calendar, Google Calendar, etc. /// /// # Arguments /// /// * `file_path` - Destination path for the ICS file /// * `include_past` - If true, include past events; if false, only future events #[tauri::command] #[instrument(skip_all)] pub async fn export_events_ics( state: State<'_, Arc>, file_path: String, include_past: Option, ) -> Result { validate_export_path(&file_path)?; let events = state.events.list_all(DESKTOP_USER_ID).await?; let include_past = include_past.unwrap_or(true); // Write ICS let file = std::fs::File::create(&file_path) .map_api_err("Failed to create ICS file", ApiError::internal)?; let item_count = ics::write_events_ics(&events, include_past, file) .map_api_err("Failed to write ICS", ApiError::internal)?; let size_bytes = std::fs::metadata(&file_path) .map(|m| m.len()) .unwrap_or(0); Ok(ExportResponse { file_path, item_count, size_bytes, }) } /// Creates a compressed backup of all data. /// /// Creates a gzip-compressed JSON file in the app's backup directory. /// This format is optimized for storage and can be restored later. #[tauri::command] #[instrument(skip_all)] pub async fn create_backup( state: State<'_, Arc>, app: tauri::AppHandle, ) -> Result { // Get backup directory let backup_dir = app .path() .app_data_dir() .map_api_err("Failed to get app data dir", ApiError::internal)? .join("backups"); std::fs::create_dir_all(&backup_dir) .map_api_err("Failed to create backup directory", ApiError::internal)?; // Generate timestamped filename let timestamp = Utc::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?; let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; let events = state.events.list_all(DESKTOP_USER_ID).await?; let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?; let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?; let export = backup::FullExport::new(projects, tasks, events, emails, contacts); let item_count = export.total_count(); let size_bytes = backup::write_backup(&export, &file_path) .map_api_err("Failed to write backup", ApiError::internal)?; Ok(ExportResponse { file_path: file_path.to_string_lossy().into_owned(), item_count, size_bytes, }) } /// Lists available backups in the backup directory. #[tauri::command] #[instrument(skip_all)] pub async fn list_backups(app: tauri::AppHandle) -> Result, ApiError> { let backup_dir = app .path() .app_data_dir() .map_api_err("Failed to get app data dir", ApiError::internal)? .join("backups"); if !backup_dir.exists() { return Ok(vec![]); } let mut backups = Vec::new(); for entry in std::fs::read_dir(&backup_dir) .map_api_err("Failed to read backup directory", ApiError::internal)? { let entry = entry .map_api_err("Failed to read directory entry", ApiError::internal)?; let path = entry.path(); if path.extension().map(|e| e == "gz").unwrap_or(false) { let metadata = entry.metadata() .map_api_err("Failed to read file metadata", ApiError::internal)?; backups.push(BackupInfoResponse { file_path: path.to_string_lossy().into_owned(), file_name: path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(), size_bytes: metadata.len(), created_at: metadata .created() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(0), }); } } // Sort by creation time, newest first backups.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(backups) } /// Information about a backup file. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupInfoResponse { /// Full path to the backup file. pub file_path: String, /// Just the filename. pub file_name: String, /// Size in bytes. pub size_bytes: u64, /// Unix timestamp of creation. pub created_at: i64, } /// Restores data from a backup file. /// /// # Arguments /// /// * `file_path` - Path to the backup file (.json.gz) /// * `options` - Restore options (replace_all: clear existing data first) /// /// # Warning /// /// If `replace_all` is true, ALL existing data will be permanently deleted /// before restoring from the backup. #[tauri::command] #[instrument(skip_all)] pub async fn restore_backup( state: State<'_, Arc>, file_path: String, options: RestoreOptions, ) -> Result { validate_export_path(&file_path)?; // Read and decompress backup let export = backup::read_backup(&file_path) .map_api_err("Failed to read backup", ApiError::bad_request)?; if options.replace_all { return Err(ApiError::internal( "Replace mode not yet implemented. Use merge mode (replaceAll: false) instead.", )); } let backup_created_at = export.exported_at.to_rfc3339(); // Delegate restore orchestration to core let input = goingson_core::backup_restore::RestoreInput { projects: export.projects, tasks: export.tasks, events: export.events, emails: export.emails, contacts: export.contacts, }; let result = goingson_core::backup_restore::restore_from_backup( DESKTOP_USER_ID, &input, state.projects.as_ref(), state.tasks.as_ref(), state.events.as_ref(), state.emails.as_ref(), state.contacts.as_ref(), ).await?; Ok(RestoreResponse { projects_restored: result.projects_restored, tasks_restored: result.tasks_restored, events_restored: result.events_restored, emails_restored: result.emails_restored, contacts_restored: result.contacts_restored, backup_created_at, }) } /// Deletes a backup file. #[tauri::command] #[instrument(skip_all)] pub async fn delete_backup( app: tauri::AppHandle, file_path: String, ) -> Result { let path = Path::new(&file_path); if !path.exists() { return Ok(false); } // Build the canonical backup directory from the app data dir let backup_dir = app .path() .app_data_dir() .map_api_err("Failed to get app data dir", ApiError::internal)? .join("backups"); let canonical_backup_dir = std::fs::canonicalize(&backup_dir) .map_api_err("Failed to resolve backup directory", ApiError::internal)?; let canonical_path = std::fs::canonicalize(path) .map_api_err("Failed to resolve file path", ApiError::internal)?; // Security check: resolved path must be inside the backup directory if !canonical_path.starts_with(&canonical_backup_dir) { return Err(ApiError::bad_request( "Can only delete files in the backups directory", )); } // Verify it's actually a backup file if !canonical_path.extension().is_some_and(|ext| ext == "gz") || !canonical_path.to_string_lossy().ends_with(".json.gz") { return Err(ApiError::bad_request( "Can only delete .json.gz backup files", )); } std::fs::remove_file(&canonical_path) .map_api_err("Failed to delete backup", ApiError::internal)?; Ok(true) } // ============ Backup Settings ============ /// Response for backup settings. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupSettingsResponse { /// Whether automatic backups are enabled. pub auto_backup_enabled: bool, /// Minutes between automatic backups. pub backup_frequency_minutes: i32, /// Maximum number of backups to retain. pub max_backups_to_keep: i32, /// When the last backup was created. pub last_backup_at: Option, } /// Input for updating backup settings. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BackupSettingsInput { /// Whether automatic backups are enabled. pub auto_backup_enabled: bool, /// Minutes between automatic backups. pub backup_frequency_minutes: i32, /// Maximum number of backups to retain. pub max_backups_to_keep: i32, } /// Gets the current backup settings. /// /// Returns default settings if none are configured. #[tauri::command] #[instrument(skip_all)] pub async fn get_backup_settings( state: State<'_, Arc>, ) -> Result { let settings = state.backup_settings.get(DESKTOP_USER_ID).await?; match settings { Some(s) => Ok(BackupSettingsResponse { auto_backup_enabled: s.auto_backup_enabled, backup_frequency_minutes: s.backup_frequency_minutes, max_backups_to_keep: s.max_backups_to_keep, last_backup_at: s.last_backup_at.map(|dt| dt.to_rfc3339()), }), None => Ok(BackupSettingsResponse { auto_backup_enabled: true, backup_frequency_minutes: 15, max_backups_to_keep: 1, last_backup_at: None, }), } } /// Updates backup settings. #[tauri::command] #[instrument(skip_all)] pub async fn save_backup_settings( state: State<'_, Arc>, input: BackupSettingsInput, ) -> Result { let settings = goingson_core::NewBackupSettings { auto_backup_enabled: input.auto_backup_enabled, backup_frequency_minutes: input.backup_frequency_minutes, max_backups_to_keep: input.max_backups_to_keep, }; let saved = state .backup_settings .upsert(DESKTOP_USER_ID, settings) .await?; Ok(BackupSettingsResponse { auto_backup_enabled: saved.auto_backup_enabled, backup_frequency_minutes: saved.backup_frequency_minutes, max_backups_to_keep: saved.max_backups_to_keep, last_backup_at: saved.last_backup_at.map(|dt| dt.to_rfc3339()), }) }