Skip to main content

max / goingson

16.6 KB · 542 lines History Blame Raw
1 //! Export and backup commands.
2 //!
3 //! Provides data export functionality in multiple formats:
4 //! - JSON: Full export of all data
5 //! - CSV: Task export for spreadsheet applications
6 //! - ICS: Calendar event export for calendar applications
7 //! - Backup: Compressed JSON with restore capability
8
9 use std::path::Path;
10 use std::sync::Arc;
11
12 use chrono::Utc;
13 use serde::{Deserialize, Serialize};
14 use tauri::{Manager, State};
15 use tracing::instrument;
16
17 use goingson_core::ProjectId;
18
19 use crate::export::{backup, csv, ics};
20 use crate::state::{AppState, DESKTOP_USER_ID};
21
22 use super::{ApiError, ResultApiError};
23
24 // ============ Path Validation ============
25
26 /// Validates that a user-supplied export/restore path does not contain `..` components.
27 ///
28 /// This is defense-in-depth for a desktop app that uses file picker dialogs --
29 /// the risk is low, but rejecting path traversal components costs nothing.
30 pub(crate) fn validate_export_path(file_path: &str) -> Result<(), ApiError> {
31 let path = Path::new(file_path);
32 for component in path.components() {
33 if matches!(component, std::path::Component::ParentDir) {
34 return Err(ApiError::bad_request(
35 "File path must not contain '..' components",
36 ));
37 }
38 }
39 Ok(())
40 }
41
42 // ============ Response Types ============
43
44 /// Result of an export operation.
45 #[derive(Debug, Serialize)]
46 #[serde(rename_all = "camelCase")]
47 pub struct ExportResponse {
48 /// Path to the exported file.
49 pub file_path: String,
50 /// Number of items exported.
51 pub item_count: usize,
52 /// Size of the exported file in bytes.
53 pub size_bytes: u64,
54 }
55
56 /// Result of a restore operation.
57 #[derive(Debug, Serialize)]
58 #[serde(rename_all = "camelCase")]
59 pub struct RestoreResponse {
60 /// Number of projects restored.
61 pub projects_restored: usize,
62 /// Number of tasks restored.
63 pub tasks_restored: usize,
64 /// Number of events restored.
65 pub events_restored: usize,
66 /// Number of emails restored.
67 pub emails_restored: usize,
68 /// Number of contacts restored.
69 pub contacts_restored: usize,
70 /// When the backup was originally created.
71 pub backup_created_at: String,
72 }
73
74 /// Summary of available data for export.
75 #[derive(Debug, Serialize)]
76 #[serde(rename_all = "camelCase")]
77 pub struct ExportSummaryResponse {
78 /// Number of projects.
79 pub project_count: usize,
80 /// Number of tasks.
81 pub task_count: usize,
82 /// Number of events.
83 pub event_count: usize,
84 /// Number of emails.
85 pub email_count: usize,
86 /// Number of contacts.
87 pub contact_count: usize,
88 }
89
90 // ============ Input Types ============
91
92 /// Options for restore operation.
93 #[derive(Debug, Deserialize)]
94 #[serde(rename_all = "camelCase")]
95 pub struct RestoreOptions {
96 /// If true, clear existing data before restore.
97 /// If false, merge with existing data (may create duplicates).
98 pub replace_all: bool,
99 }
100
101 // ============ Commands ============
102
103 /// Gets a summary of data available for export.
104 ///
105 /// Useful for showing the user what will be exported before they commit.
106 #[tauri::command]
107 #[instrument(skip_all)]
108 pub async fn get_export_summary(state: State<'_, Arc<AppState>>) -> Result<ExportSummaryResponse, ApiError> {
109 let (projects, tasks, events, emails, contacts) = tokio::join!(
110 state.projects.list_all(DESKTOP_USER_ID),
111 state.tasks.list_all(DESKTOP_USER_ID),
112 state.events.list_all(DESKTOP_USER_ID),
113 state.emails.list_all(DESKTOP_USER_ID, true),
114 state.contacts.list_all(DESKTOP_USER_ID),
115 );
116
117 Ok(ExportSummaryResponse {
118 project_count: projects?.len(),
119 task_count: tasks?.len(),
120 event_count: events?.len(),
121 email_count: emails?.len(),
122 contact_count: contacts?.len(),
123 })
124 }
125
126 /// Exports all data as JSON.
127 ///
128 /// Creates a human-readable JSON file containing all projects, tasks, events, and emails.
129 /// This format is best for manual inspection or migration to other systems.
130 ///
131 /// # Arguments
132 ///
133 /// * `file_path` - Destination path for the JSON file
134 #[tauri::command]
135 #[instrument(skip_all)]
136 pub async fn export_json(
137 state: State<'_, Arc<AppState>>,
138 file_path: String,
139 ) -> Result<ExportResponse, ApiError> {
140 validate_export_path(&file_path)?;
141
142 // Fetch all data
143 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
144 let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
145 let events = state.events.list_all(DESKTOP_USER_ID).await?;
146 let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
147 let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?;
148
149 let export = backup::FullExport::new(projects, tasks, events, emails, contacts);
150 let item_count = export.total_count();
151
152 let size_bytes = backup::write_json(&export, &file_path)
153 .map_api_err("Failed to write JSON export", ApiError::internal)?;
154
155 Ok(ExportResponse {
156 file_path,
157 item_count,
158 size_bytes,
159 })
160 }
161
162 /// Exports tasks as CSV.
163 ///
164 /// Creates a spreadsheet-compatible CSV file. Optionally filter by project.
165 ///
166 /// # Arguments
167 ///
168 /// * `file_path` - Destination path for the CSV file
169 /// * `project_id` - Optional project ID to filter tasks
170 #[tauri::command]
171 #[instrument(skip_all)]
172 pub async fn export_tasks_csv(
173 state: State<'_, Arc<AppState>>,
174 file_path: String,
175 project_id: Option<ProjectId>,
176 ) -> Result<ExportResponse, ApiError> {
177 validate_export_path(&file_path)?;
178
179 // Fetch tasks (filtered or all)
180 let tasks = if let Some(pid) = project_id {
181 state.tasks.list_by_project(DESKTOP_USER_ID, pid).await?
182 } else {
183 state.tasks.list_all(DESKTOP_USER_ID).await?
184 };
185
186 // Fetch projects for name lookup
187 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
188
189 // Write CSV
190 let file = std::fs::File::create(&file_path)
191 .map_api_err("Failed to create CSV file", ApiError::internal)?;
192
193 let item_count = csv::write_tasks_csv(&tasks, &projects, file)
194 .map_api_err("Failed to write CSV", ApiError::internal)?;
195
196 let size_bytes = std::fs::metadata(&file_path)
197 .map(|m| m.len())
198 .unwrap_or(0);
199
200 Ok(ExportResponse {
201 file_path,
202 item_count,
203 size_bytes,
204 })
205 }
206
207 /// Exports events as ICS (iCalendar).
208 ///
209 /// Creates a calendar file that can be imported into Apple Calendar, Google Calendar, etc.
210 ///
211 /// # Arguments
212 ///
213 /// * `file_path` - Destination path for the ICS file
214 /// * `include_past` - If true, include past events; if false, only future events
215 #[tauri::command]
216 #[instrument(skip_all)]
217 pub async fn export_events_ics(
218 state: State<'_, Arc<AppState>>,
219 file_path: String,
220 include_past: Option<bool>,
221 ) -> Result<ExportResponse, ApiError> {
222 validate_export_path(&file_path)?;
223
224 let events = state.events.list_all(DESKTOP_USER_ID).await?;
225 let include_past = include_past.unwrap_or(true);
226
227 // Write ICS
228 let file = std::fs::File::create(&file_path)
229 .map_api_err("Failed to create ICS file", ApiError::internal)?;
230
231 let item_count = ics::write_events_ics(&events, include_past, file)
232 .map_api_err("Failed to write ICS", ApiError::internal)?;
233
234 let size_bytes = std::fs::metadata(&file_path)
235 .map(|m| m.len())
236 .unwrap_or(0);
237
238 Ok(ExportResponse {
239 file_path,
240 item_count,
241 size_bytes,
242 })
243 }
244
245 /// Creates a compressed backup of all data.
246 ///
247 /// Creates a gzip-compressed JSON file in the app's backup directory.
248 /// This format is optimized for storage and can be restored later.
249 #[tauri::command]
250 #[instrument(skip_all)]
251 pub async fn create_backup(
252 state: State<'_, Arc<AppState>>,
253 app: tauri::AppHandle,
254 ) -> Result<ExportResponse, ApiError> {
255 // Get backup directory
256 let backup_dir = app
257 .path()
258 .app_data_dir()
259 .map_api_err("Failed to get app data dir", ApiError::internal)?
260 .join("backups");
261
262 std::fs::create_dir_all(&backup_dir)
263 .map_api_err("Failed to create backup directory", ApiError::internal)?;
264
265 // Generate timestamped filename
266 let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
267 let filename = format!("goingson-backup-{}.json.gz", timestamp);
268 let file_path = backup_dir.join(&filename);
269
270 // Fetch all data
271 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
272 let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
273 let events = state.events.list_all(DESKTOP_USER_ID).await?;
274 let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
275 let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?;
276
277 let export = backup::FullExport::new(projects, tasks, events, emails, contacts);
278 let item_count = export.total_count();
279
280 let size_bytes = backup::write_backup(&export, &file_path)
281 .map_api_err("Failed to write backup", ApiError::internal)?;
282
283 Ok(ExportResponse {
284 file_path: file_path.to_string_lossy().into_owned(),
285 item_count,
286 size_bytes,
287 })
288 }
289
290 /// Lists available backups in the backup directory.
291 #[tauri::command]
292 #[instrument(skip_all)]
293 pub async fn list_backups(app: tauri::AppHandle) -> Result<Vec<BackupInfoResponse>, ApiError> {
294 let backup_dir = app
295 .path()
296 .app_data_dir()
297 .map_api_err("Failed to get app data dir", ApiError::internal)?
298 .join("backups");
299
300 if !backup_dir.exists() {
301 return Ok(vec![]);
302 }
303
304 let mut backups = Vec::new();
305
306 for entry in std::fs::read_dir(&backup_dir)
307 .map_api_err("Failed to read backup directory", ApiError::internal)?
308 {
309 let entry = entry
310 .map_api_err("Failed to read directory entry", ApiError::internal)?;
311
312 let path = entry.path();
313 if path.extension().map(|e| e == "gz").unwrap_or(false) {
314 let metadata = entry.metadata()
315 .map_api_err("Failed to read file metadata", ApiError::internal)?;
316
317 backups.push(BackupInfoResponse {
318 file_path: path.to_string_lossy().into_owned(),
319 file_name: path
320 .file_name()
321 .map(|n| n.to_string_lossy().into_owned())
322 .unwrap_or_default(),
323 size_bytes: metadata.len(),
324 created_at: metadata
325 .created()
326 .ok()
327 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
328 .map(|d| d.as_secs() as i64)
329 .unwrap_or(0),
330 });
331 }
332 }
333
334 // Sort by creation time, newest first
335 backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
336
337 Ok(backups)
338 }
339
340 /// Information about a backup file.
341 #[derive(Debug, Serialize)]
342 #[serde(rename_all = "camelCase")]
343 pub struct BackupInfoResponse {
344 /// Full path to the backup file.
345 pub file_path: String,
346 /// Just the filename.
347 pub file_name: String,
348 /// Size in bytes.
349 pub size_bytes: u64,
350 /// Unix timestamp of creation.
351 pub created_at: i64,
352 }
353
354 /// Restores data from a backup file.
355 ///
356 /// # Arguments
357 ///
358 /// * `file_path` - Path to the backup file (.json.gz)
359 /// * `options` - Restore options (replace_all: clear existing data first)
360 ///
361 /// # Warning
362 ///
363 /// If `replace_all` is true, ALL existing data will be permanently deleted
364 /// before restoring from the backup.
365 #[tauri::command]
366 #[instrument(skip_all)]
367 pub async fn restore_backup(
368 state: State<'_, Arc<AppState>>,
369 file_path: String,
370 options: RestoreOptions,
371 ) -> Result<RestoreResponse, ApiError> {
372 validate_export_path(&file_path)?;
373
374 // Read and decompress backup
375 let export = backup::read_backup(&file_path)
376 .map_api_err("Failed to read backup", ApiError::bad_request)?;
377
378 if options.replace_all {
379 return Err(ApiError::internal(
380 "Replace mode not yet implemented. Use merge mode (replaceAll: false) instead.",
381 ));
382 }
383
384 let backup_created_at = export.exported_at.to_rfc3339();
385
386 // Delegate restore orchestration to core
387 let input = goingson_core::backup_restore::RestoreInput {
388 projects: export.projects,
389 tasks: export.tasks,
390 events: export.events,
391 emails: export.emails,
392 contacts: export.contacts,
393 };
394
395 let result = goingson_core::backup_restore::restore_from_backup(
396 DESKTOP_USER_ID,
397 &input,
398 state.projects.as_ref(),
399 state.tasks.as_ref(),
400 state.events.as_ref(),
401 state.emails.as_ref(),
402 state.contacts.as_ref(),
403 ).await?;
404
405 Ok(RestoreResponse {
406 projects_restored: result.projects_restored,
407 tasks_restored: result.tasks_restored,
408 events_restored: result.events_restored,
409 emails_restored: result.emails_restored,
410 contacts_restored: result.contacts_restored,
411 backup_created_at,
412 })
413 }
414
415 /// Deletes a backup file.
416 #[tauri::command]
417 #[instrument(skip_all)]
418 pub async fn delete_backup(
419 app: tauri::AppHandle,
420 file_path: String,
421 ) -> Result<bool, ApiError> {
422 let path = Path::new(&file_path);
423
424 if !path.exists() {
425 return Ok(false);
426 }
427
428 // Build the canonical backup directory from the app data dir
429 let backup_dir = app
430 .path()
431 .app_data_dir()
432 .map_api_err("Failed to get app data dir", ApiError::internal)?
433 .join("backups");
434
435 let canonical_backup_dir = std::fs::canonicalize(&backup_dir)
436 .map_api_err("Failed to resolve backup directory", ApiError::internal)?;
437
438 let canonical_path = std::fs::canonicalize(path)
439 .map_api_err("Failed to resolve file path", ApiError::internal)?;
440
441 // Security check: resolved path must be inside the backup directory
442 if !canonical_path.starts_with(&canonical_backup_dir) {
443 return Err(ApiError::bad_request(
444 "Can only delete files in the backups directory",
445 ));
446 }
447
448 // Verify it's actually a backup file
449 if !canonical_path.extension().is_some_and(|ext| ext == "gz")
450 || !canonical_path.to_string_lossy().ends_with(".json.gz")
451 {
452 return Err(ApiError::bad_request(
453 "Can only delete .json.gz backup files",
454 ));
455 }
456
457 std::fs::remove_file(&canonical_path)
458 .map_api_err("Failed to delete backup", ApiError::internal)?;
459
460 Ok(true)
461 }
462
463 // ============ Backup Settings ============
464
465 /// Response for backup settings.
466 #[derive(Debug, Serialize)]
467 #[serde(rename_all = "camelCase")]
468 pub struct BackupSettingsResponse {
469 /// Whether automatic backups are enabled.
470 pub auto_backup_enabled: bool,
471 /// Minutes between automatic backups.
472 pub backup_frequency_minutes: i32,
473 /// Maximum number of backups to retain.
474 pub max_backups_to_keep: i32,
475 /// When the last backup was created.
476 pub last_backup_at: Option<String>,
477 }
478
479 /// Input for updating backup settings.
480 #[derive(Debug, Deserialize)]
481 #[serde(rename_all = "camelCase")]
482 pub struct BackupSettingsInput {
483 /// Whether automatic backups are enabled.
484 pub auto_backup_enabled: bool,
485 /// Minutes between automatic backups.
486 pub backup_frequency_minutes: i32,
487 /// Maximum number of backups to retain.
488 pub max_backups_to_keep: i32,
489 }
490
491 /// Gets the current backup settings.
492 ///
493 /// Returns default settings if none are configured.
494 #[tauri::command]
495 #[instrument(skip_all)]
496 pub async fn get_backup_settings(
497 state: State<'_, Arc<AppState>>,
498 ) -> Result<BackupSettingsResponse, ApiError> {
499 let settings = state.backup_settings.get(DESKTOP_USER_ID).await?;
500
501 match settings {
502 Some(s) => Ok(BackupSettingsResponse {
503 auto_backup_enabled: s.auto_backup_enabled,
504 backup_frequency_minutes: s.backup_frequency_minutes,
505 max_backups_to_keep: s.max_backups_to_keep,
506 last_backup_at: s.last_backup_at.map(|dt| dt.to_rfc3339()),
507 }),
508 None => Ok(BackupSettingsResponse {
509 auto_backup_enabled: true,
510 backup_frequency_minutes: 15,
511 max_backups_to_keep: 1,
512 last_backup_at: None,
513 }),
514 }
515 }
516
517 /// Updates backup settings.
518 #[tauri::command]
519 #[instrument(skip_all)]
520 pub async fn save_backup_settings(
521 state: State<'_, Arc<AppState>>,
522 input: BackupSettingsInput,
523 ) -> Result<BackupSettingsResponse, ApiError> {
524 let settings = goingson_core::NewBackupSettings {
525 auto_backup_enabled: input.auto_backup_enabled,
526 backup_frequency_minutes: input.backup_frequency_minutes,
527 max_backups_to_keep: input.max_backups_to_keep,
528 };
529
530 let saved = state
531 .backup_settings
532 .upsert(DESKTOP_USER_ID, settings)
533 .await?;
534
535 Ok(BackupSettingsResponse {
536 auto_backup_enabled: saved.auto_backup_enabled,
537 backup_frequency_minutes: saved.backup_frequency_minutes,
538 max_backups_to_keep: saved.max_backups_to_keep,
539 last_backup_at: saved.last_backup_at.map(|dt| dt.to_rfc3339()),
540 })
541 }
542