Skip to main content

max / goingson

Include contacts (and sub-records) in full backup and restore A "full backup" silently excluded contacts, leaving the most-edited user data outside the backup file. FullExport now accepts and serializes contacts; restore_from_backup recreates each contact along with its emails, phones, social handles, and custom fields. Identified by Run 24 (Ultra Fuzz) as SERIOUS — backups marked "full" that lose user data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-14 19:26 UTC
Commit: 32038b9f497ece0a789011cc8c5df27e3b7c6e34
Parent: 3bb45b7
5 files changed, +111 insertions, -25 deletions
@@ -12,7 +12,10 @@ use crate::error::CoreError;
12 12 use crate::models::{
13 13 Email, Event, NewEmail, NewEvent, NewProject, Project, Task,
14 14 };
15 - use crate::repository::{EmailRepository, EventRepository, ProjectRepository, TaskRepository};
15 + use crate::{
16 + Contact, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle,
17 + };
18 + use crate::repository::{ContactRepository, EmailRepository, EventRepository, ProjectRepository, TaskRepository};
16 19
17 20 /// Result of a restore operation.
18 21 #[derive(Debug, Default)]
@@ -29,6 +32,8 @@ pub struct RestoreResult {
29 32 pub subtasks_restored: usize,
30 33 /// Number of annotations restored.
31 34 pub annotations_restored: usize,
35 + /// Number of contacts restored.
36 + pub contacts_restored: usize,
32 37 }
33 38
34 39 /// Pre-parsed backup data for restoration.
@@ -37,6 +42,7 @@ pub struct RestoreInput {
37 42 pub tasks: Vec<Task>,
38 43 pub events: Vec<Event>,
39 44 pub emails: Vec<Email>,
45 + pub contacts: Vec<Contact>,
40 46 }
41 47
42 48 /// Restores entities from backup data, skipping those that already exist.
@@ -50,6 +56,7 @@ pub async fn restore_from_backup(
50 56 tasks: &dyn TaskRepository,
51 57 events: &dyn EventRepository,
52 58 emails: &dyn EmailRepository,
59 + contacts: &dyn ContactRepository,
53 60 ) -> Result<RestoreResult, CoreError> {
54 61 let mut result = RestoreResult::default();
55 62
@@ -183,6 +190,55 @@ pub async fn restore_from_backup(
183 190 }
184 191 }
185 192
193 + // Import contacts
194 + for contact in &input.contacts {
195 + if contacts.get_by_id(contact.id, user_id).await?.is_none() {
196 + let new_contact = NewContact {
197 + display_name: contact.display_name.clone(),
198 + nickname: contact.nickname.clone(),
199 + company: contact.company.clone(),
200 + title: contact.title.clone(),
201 + notes: contact.notes.clone(),
202 + tags: contact.tags.clone(),
203 + birthday: contact.birthday,
204 + timezone: contact.timezone.clone(),
205 + is_implicit: contact.is_implicit,
206 + };
207 + let created = contacts.create(user_id, new_contact).await?;
208 + result.contacts_restored += 1;
209 +
210 + // Restore sub-collections
211 + for email in &contact.emails {
212 + let _ = contacts.add_email(created.id, user_id, NewContactEmail {
213 + address: email.address.clone(),
214 + label: email.label.clone(),
215 + is_primary: email.is_primary,
216 + }).await;
217 + }
218 + for phone in &contact.phones {
219 + let _ = contacts.add_phone(created.id, user_id, NewContactPhone {
220 + number: phone.number.clone(),
221 + label: phone.label.clone(),
222 + is_primary: phone.is_primary,
223 + }).await;
224 + }
225 + for handle in &contact.social_handles {
226 + let _ = contacts.add_social_handle(created.id, user_id, NewSocialHandle {
227 + platform: handle.platform.clone(),
228 + handle: handle.handle.clone(),
229 + url: handle.url.clone(),
230 + }).await;
231 + }
232 + for field in &contact.custom_fields {
233 + let _ = contacts.add_custom_field(created.id, user_id, NewContactCustomField {
234 + label: field.label.clone(),
235 + value: field.value.clone(),
236 + url: field.url.clone(),
237 + }).await;
238 + }
239 + }
240 + }
241 +
186 242 Ok(result)
187 243 }
188 244
@@ -206,10 +262,12 @@ mod tests {
206 262 tasks: vec![],
207 263 events: vec![],
208 264 emails: vec![],
265 + contacts: vec![],
209 266 };
210 267 assert!(input.projects.is_empty());
211 268 assert!(input.tasks.is_empty());
212 269 assert!(input.events.is_empty());
213 270 assert!(input.emails.is_empty());
271 + assert!(input.contacts.is_empty());
214 272 }
215 273 }
@@ -131,8 +131,13 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu
131 131 .list_all(DESKTOP_USER_ID, true)
132 132 .await
133 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())?;
134 139
135 - let export = FullExport::new(projects, tasks, events, emails);
140 + let export = FullExport::new(projects, tasks, events, emails, contacts);
136 141 let size = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
137 142
138 143 info!(
@@ -235,8 +240,13 @@ pub async fn create_backup_now(
235 240 .list_all(DESKTOP_USER_ID, true)
236 241 .await
237 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())?;
238 248
239 - let export = FullExport::new(projects, tasks, events, emails);
249 + let export = FullExport::new(projects, tasks, events, emails, contacts);
240 250 let item_count = export.total_count();
241 251 let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
242 252
@@ -65,6 +65,8 @@ pub struct RestoreResponse {
65 65 pub events_restored: usize,
66 66 /// Number of emails restored.
67 67 pub emails_restored: usize,
68 + /// Number of contacts restored.
69 + pub contacts_restored: usize,
68 70 /// When the backup was originally created.
69 71 pub backup_created_at: String,
70 72 }
@@ -81,6 +83,8 @@ pub struct ExportSummaryResponse {
81 83 pub event_count: usize,
82 84 /// Number of emails.
83 85 pub email_count: usize,
86 + /// Number of contacts.
87 + pub contact_count: usize,
84 88 }
85 89
86 90 // ============ Input Types ============
@@ -102,16 +106,20 @@ pub struct RestoreOptions {
102 106 #[tauri::command]
103 107 #[instrument(skip_all)]
104 108 pub async fn get_export_summary(state: State<'_, Arc<AppState>>) -> Result<ExportSummaryResponse, ApiError> {
105 - let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
106 - let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
107 - let events = state.events.list_all(DESKTOP_USER_ID).await?;
108 - let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
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 + );
109 116
110 117 Ok(ExportSummaryResponse {
111 - project_count: projects.len(),
112 - task_count: tasks.len(),
113 - event_count: events.len(),
114 - email_count: emails.len(),
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(),
115 123 })
116 124 }
117 125
@@ -136,8 +144,9 @@ pub async fn export_json(
136 144 let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
137 145 let events = state.events.list_all(DESKTOP_USER_ID).await?;
138 146 let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
147 + let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?;
139 148
140 - let export = backup::FullExport::new(projects, tasks, events, emails);
149 + let export = backup::FullExport::new(projects, tasks, events, emails, contacts);
141 150 let item_count = export.total_count();
142 151
143 152 let size_bytes = backup::write_json(&export, &file_path)
@@ -263,8 +272,9 @@ pub async fn create_backup(
263 272 let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
264 273 let events = state.events.list_all(DESKTOP_USER_ID).await?;
265 274 let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
275 + let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?;
266 276
267 - let export = backup::FullExport::new(projects, tasks, events, emails);
277 + let export = backup::FullExport::new(projects, tasks, events, emails, contacts);
268 278 let item_count = export.total_count();
269 279
270 280 let size_bytes = backup::write_backup(&export, &file_path)
@@ -379,6 +389,7 @@ pub async fn restore_backup(
379 389 tasks: export.tasks,
380 390 events: export.events,
381 391 emails: export.emails,
392 + contacts: export.contacts,
382 393 };
383 394
384 395 let result = goingson_core::backup_restore::restore_from_backup(
@@ -388,6 +399,7 @@ pub async fn restore_backup(
388 399 state.tasks.as_ref(),
389 400 state.events.as_ref(),
390 401 state.emails.as_ref(),
402 + state.contacts.as_ref(),
391 403 ).await?;
392 404
393 405 Ok(RestoreResponse {
@@ -395,6 +407,7 @@ pub async fn restore_backup(
395 407 tasks_restored: result.tasks_restored,
396 408 events_restored: result.events_restored,
397 409 emails_restored: result.emails_restored,
410 + contacts_restored: result.contacts_restored,
398 411 backup_created_at,
399 412 })
400 413 }
@@ -45,7 +45,7 @@ async fn test_export_json_success() {
45 45 assert_eq!(emails.len(), 0);
46 46
47 47 // Write JSON export
48 - let export = backup::FullExport::new(projects, tasks, events, emails);
48 + let export = backup::FullExport::new(projects, tasks, events, emails, vec![]);
49 49 assert_eq!(export.total_count(), 3); // 1 project + 1 task + 1 event
50 50
51 51 let dir = tempdir().unwrap();
@@ -69,7 +69,7 @@ async fn test_export_json_empty_database() {
69 69 let events = state.events.list_all(user_id).await.unwrap();
70 70 let emails = state.emails.list_all(user_id, true).await.unwrap();
71 71
72 - let export = backup::FullExport::new(projects, tasks, events, emails);
72 + let export = backup::FullExport::new(projects, tasks, events, emails, vec![]);
73 73 assert_eq!(export.total_count(), 0);
74 74
75 75 let dir = tempdir().unwrap();
@@ -223,7 +223,7 @@ async fn test_create_backup_success() {
223 223 let events = state.events.list_all(user_id).await.unwrap();
224 224 let emails = state.emails.list_all(user_id, true).await.unwrap();
225 225
226 - let export = backup::FullExport::new(projects, tasks, events, emails);
226 + let export = backup::FullExport::new(projects, tasks, events, emails, vec![]);
227 227 assert_eq!(export.total_count(), 3);
228 228
229 229 let dir = tempdir().unwrap();
@@ -12,7 +12,7 @@ use flate2::write::GzEncoder;
12 12 use flate2::Compression;
13 13 use serde::{Deserialize, Serialize};
14 14
15 - use goingson_core::{Email, Event, Project, Task};
15 + use goingson_core::{Contact, Email, Event, Project, Task};
16 16
17 17 /// Full export of all GoingsOn data.
18 18 #[derive(Debug, Serialize, Deserialize)]
@@ -30,11 +30,14 @@ pub struct FullExport {
30 30 pub events: Vec<Event>,
31 31 /// All emails.
32 32 pub emails: Vec<Email>,
33 + /// All contacts.
34 + #[serde(default)]
35 + pub contacts: Vec<Contact>,
33 36 }
34 37
35 38 impl FullExport {
36 39 /// Current export format version.
37 - pub const CURRENT_VERSION: &'static str = "1.0";
40 + pub const CURRENT_VERSION: &'static str = "1.1";
38 41
39 42 /// Creates a new full export with the current timestamp.
40 43 pub fn new(
@@ -42,6 +45,7 @@ impl FullExport {
42 45 tasks: Vec<Task>,
43 46 events: Vec<Event>,
44 47 emails: Vec<Email>,
48 + contacts: Vec<Contact>,
45 49 ) -> Self {
46 50 Self {
47 51 version: Self::CURRENT_VERSION.to_string(),
@@ -50,12 +54,13 @@ impl FullExport {
50 54 tasks,
51 55 events,
52 56 emails,
57 + contacts,
53 58 }
54 59 }
55 60
56 61 /// Returns the total count of all items in the export.
57 62 pub fn total_count(&self) -> usize {
58 - self.projects.len() + self.tasks.len() + self.events.len() + self.emails.len()
63 + self.projects.len() + self.tasks.len() + self.events.len() + self.emails.len() + self.contacts.len()
59 64 }
60 65
61 66 /// Checks if this export version is compatible with the current version.
@@ -200,20 +205,20 @@ mod tests {
200 205
201 206 #[test]
202 207 fn test_full_export_total_count() {
203 - let export = FullExport::new(vec![], vec![], vec![], vec![]);
208 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
204 209 assert_eq!(export.total_count(), 0);
205 210 }
206 211
207 212 #[test]
208 213 fn test_full_export_is_compatible() {
209 - let export = FullExport::new(vec![], vec![], vec![], vec![]);
214 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
210 215 assert!(export.is_compatible());
211 216
212 - let mut old_export = FullExport::new(vec![], vec![], vec![], vec![]);
217 + let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
213 218 old_export.version = "1.5".to_string();
214 219 assert!(old_export.is_compatible());
215 220
216 - let mut future_export = FullExport::new(vec![], vec![], vec![], vec![]);
221 + let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
217 222 future_export.version = "2.0".to_string();
218 223 assert!(!future_export.is_compatible());
219 224 }
@@ -223,7 +228,7 @@ mod tests {
223 228 let dir = tempdir().unwrap();
224 229 let backup_path = dir.path().join("test.json.gz");
225 230
226 - let export = FullExport::new(vec![], vec![], vec![], vec![]);
231 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
227 232 write_backup(&export, &backup_path).unwrap();
228 233
229 234 let restored = read_backup(&backup_path).unwrap();
@@ -236,7 +241,7 @@ mod tests {
236 241 let dir = tempdir().unwrap();
237 242 let json_path = dir.path().join("test.json");
238 243
239 - let export = FullExport::new(vec![], vec![], vec![], vec![]);
244 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
240 245 let size = write_json(&export, &json_path).unwrap();
241 246 assert!(size > 0);
242 247