Skip to main content

max / goingson

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