Skip to main content

max / goingson

8.3 KB · 271 lines History Blame Raw
1 //! Automated backup scheduler.
2 //!
3 //! Runs in the background and creates compressed backups based on user settings.
4 //! Handles backup retention by pruning old backups when max count is exceeded.
5
6 use crate::export::backup::{write_backup, FullExport};
7 use crate::state::{AppState, DESKTOP_USER_ID};
8 use chrono::Utc;
9 use std::sync::Arc;
10 use tauri::Manager;
11 use tokio_util::sync::CancellationToken;
12 use tracing::{debug, error, info, warn};
13
14 /// Check interval for automated backups (1 minute)
15 const CHECK_INTERVAL_SECS: u64 = 60;
16
17 /// Starts the background backup scheduler that creates automatic backups
18 /// based on user settings and prunes old backups.
19 pub async fn start_backup_scheduler(app: tauri::AppHandle, cancel: CancellationToken) {
20 info!(
21 "Starting backup scheduler (check interval: {}s)",
22 CHECK_INTERVAL_SECS
23 );
24 let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS));
25
26 // Skip the first immediate tick
27 interval.tick().await;
28
29 loop {
30 tokio::select! {
31 _ = cancel.cancelled() => {
32 info!("Backup scheduler shutting down");
33 break;
34 }
35 _ = interval.tick() => {}
36 }
37
38 // Get app state
39 let state = match app.try_state::<Arc<AppState>>() {
40 Some(s) => s,
41 None => {
42 debug!("App state not available, skipping backup check");
43 continue;
44 }
45 };
46
47 // Check if backup is needed and perform it
48 if let Err(e) = check_and_backup(&app, &state).await {
49 error!(error = %e, "Error in backup scheduler");
50 }
51 }
52 }
53
54 /// Checks if a backup is needed based on settings and performs it if necessary.
55 async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Result<(), String> {
56 // Get backup settings (create defaults if not set)
57 let settings = match state.backup_settings.get(DESKTOP_USER_ID).await {
58 Ok(Some(s)) => s,
59 Ok(None) => {
60 // Create default settings
61 let defaults = goingson_core::NewBackupSettings {
62 auto_backup_enabled: true,
63 backup_frequency_minutes: 15,
64 max_backups_to_keep: 1,
65 };
66 state
67 .backup_settings
68 .upsert(DESKTOP_USER_ID, defaults)
69 .await
70 .map_err(|e| e.to_string())?
71 }
72 Err(e) => return Err(format!("Failed to get backup settings: {}", e)),
73 };
74
75 // Check if auto backup is enabled
76 if !settings.auto_backup_enabled {
77 debug!("Auto backup is disabled");
78 return Ok(());
79 }
80
81 // Check if enough time has passed since last backup
82 let now = Utc::now();
83 let should_backup = match settings.last_backup_at {
84 Some(last) => {
85 let minutes_since = (now - last).num_minutes();
86 minutes_since >= settings.backup_frequency_minutes as i64
87 }
88 None => true, // Never backed up, do it now
89 };
90
91 if !should_backup {
92 debug!("Backup not needed yet");
93 return Ok(());
94 }
95
96 info!("Starting automated backup");
97
98 // Perform the backup
99 let backup_dir = app
100 .path()
101 .app_data_dir()
102 .map_err(|e| format!("Failed to get app data dir: {}", e))?
103 .join("backups");
104
105 std::fs::create_dir_all(&backup_dir)
106 .map_err(|e| format!("Failed to create backup directory: {}", e))?;
107
108 // Generate timestamped filename
109 let timestamp = now.format("%Y%m%d-%H%M%S");
110 let filename = format!("goingson-backup-{}.json.gz", timestamp);
111 let file_path = backup_dir.join(&filename);
112
113 // Fetch all data
114 let projects = state
115 .projects
116 .list_all(DESKTOP_USER_ID)
117 .await
118 .map_err(|e| e.to_string())?;
119 let tasks = state
120 .tasks
121 .list_all(DESKTOP_USER_ID)
122 .await
123 .map_err(|e| e.to_string())?;
124 let events = state
125 .events
126 .list_all(DESKTOP_USER_ID)
127 .await
128 .map_err(|e| e.to_string())?;
129 let emails = state
130 .emails
131 .list_all(DESKTOP_USER_ID, true)
132 .await
133 .map_err(|e| e.to_string())?;
134 let contacts = state
135 .contacts
136 .list_all(DESKTOP_USER_ID)
137 .await
138 .map_err(|e| e.to_string())?;
139
140 let export = FullExport::new(projects, tasks, events, emails, contacts);
141 let size = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
142
143 info!(
144 path = %file_path.display(),
145 size_bytes = size,
146 items = export.total_count(),
147 "Automated backup completed"
148 );
149
150 // Update last backup timestamp
151 state
152 .backup_settings
153 .update_last_backup_at(DESKTOP_USER_ID, now)
154 .await
155 .map_err(|e| format!("Failed to update last backup time: {}", e))?;
156
157 // Prune old backups
158 prune_old_backups(&backup_dir, settings.max_backups_to_keep as usize)?;
159
160 Ok(())
161 }
162
163 /// Removes old backups to maintain the maximum count.
164 fn prune_old_backups(backup_dir: &std::path::Path, max_to_keep: usize) -> Result<(), String> {
165 if max_to_keep == 0 {
166 return Ok(()); // Keep all backups
167 }
168
169 let mut backups: Vec<_> = std::fs::read_dir(backup_dir)
170 .map_err(|e| format!("Failed to read backup directory: {}", e))?
171 .filter_map(|entry| entry.ok())
172 .filter(|entry| {
173 entry
174 .path()
175 .extension()
176 .map(|ext| ext == "gz")
177 .unwrap_or(false)
178 })
179 .filter_map(|entry| {
180 entry
181 .metadata()
182 .ok()
183 .and_then(|m| m.created().ok())
184 .map(|created| (entry.path(), created))
185 })
186 .collect();
187
188 // Sort by creation time, newest first
189 backups.sort_by(|a, b| b.1.cmp(&a.1));
190
191 // Remove backups beyond the limit
192 for (path, _) in backups.into_iter().skip(max_to_keep) {
193 info!(path = %path.display(), "Pruning old backup");
194 if let Err(e) = std::fs::remove_file(&path) {
195 warn!(path = %path.display(), error = %e, "Failed to remove old backup");
196 }
197 }
198
199 Ok(())
200 }
201
202 /// Performs an immediate backup (for manual trigger or on-demand).
203 pub async fn create_backup_now(
204 app: &tauri::AppHandle,
205 state: &Arc<AppState>,
206 ) -> Result<crate::commands::ExportResponse, String> {
207 let now = Utc::now();
208
209 let backup_dir = app
210 .path()
211 .app_data_dir()
212 .map_err(|e| format!("Failed to get app data dir: {}", e))?
213 .join("backups");
214
215 std::fs::create_dir_all(&backup_dir)
216 .map_err(|e| format!("Failed to create backup directory: {}", e))?;
217
218 let timestamp = now.format("%Y%m%d-%H%M%S");
219 let filename = format!("goingson-backup-{}.json.gz", timestamp);
220 let file_path = backup_dir.join(&filename);
221
222 // Fetch all data
223 let projects = state
224 .projects
225 .list_all(DESKTOP_USER_ID)
226 .await
227 .map_err(|e| e.to_string())?;
228 let tasks = state
229 .tasks
230 .list_all(DESKTOP_USER_ID)
231 .await
232 .map_err(|e| e.to_string())?;
233 let events = state
234 .events
235 .list_all(DESKTOP_USER_ID)
236 .await
237 .map_err(|e| e.to_string())?;
238 let emails = state
239 .emails
240 .list_all(DESKTOP_USER_ID, true)
241 .await
242 .map_err(|e| e.to_string())?;
243 let contacts = state
244 .contacts
245 .list_all(DESKTOP_USER_ID)
246 .await
247 .map_err(|e| e.to_string())?;
248
249 let export = FullExport::new(projects, tasks, events, emails, contacts);
250 let item_count = export.total_count();
251 let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
252
253 // Update last backup timestamp
254 state
255 .backup_settings
256 .update_last_backup_at(DESKTOP_USER_ID, now)
257 .await
258 .map_err(|e| format!("Failed to update last backup time: {}", e))?;
259
260 // Prune old backups if settings exist
261 if let Ok(Some(settings)) = state.backup_settings.get(DESKTOP_USER_ID).await {
262 let _ = prune_old_backups(&backup_dir, settings.max_backups_to_keep as usize);
263 }
264
265 Ok(crate::commands::ExportResponse {
266 file_path: file_path.to_string_lossy().into_owned(),
267 item_count,
268 size_bytes,
269 })
270 }
271