//! Backup restore orchestration logic. //! //! Contains the entity iteration and existence-check-before-create pattern //! for restoring data from backups. The command layer handles file I/O; //! this module handles the restore logic using repository trait objects. use std::collections::HashMap; use crate::id_types::{ProjectId, TaskId, UserId}; use crate::error::CoreError; use crate::models::{ Email, Event, NewEmail, NewEvent, NewProject, Project, Task, }; use crate::{ Contact, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, }; use crate::repository::{ContactRepository, EmailRepository, EventRepository, ProjectRepository, TaskRepository}; /// Result of a restore operation. #[derive(Debug, Default)] pub struct RestoreResult { /// 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 subtasks restored. pub subtasks_restored: usize, /// Number of annotations restored. pub annotations_restored: usize, /// Number of contacts restored. pub contacts_restored: usize, } /// Pre-parsed backup data for restoration. pub struct RestoreInput { pub projects: Vec, pub tasks: Vec, pub events: Vec, pub emails: Vec, pub contacts: Vec, } /// Restores entities from backup data, skipping those that already exist. /// /// Uses existence checks (by ID or message_id) to avoid duplicates. /// This is a merge operation — existing data is preserved. pub async fn restore_from_backup( user_id: UserId, input: &RestoreInput, projects: &dyn ProjectRepository, tasks: &dyn TaskRepository, events: &dyn EventRepository, emails: &dyn EmailRepository, contacts: &dyn ContactRepository, ) -> Result { let mut result = RestoreResult::default(); // Import projects, tracking old-to-new ID mapping let mut project_id_map: HashMap = HashMap::new(); for project in &input.projects { if projects.get_by_id(project.id, user_id).await?.is_none() { let new_project = NewProject { name: project.name.clone(), description: project.description.clone(), project_type: project.project_type.clone(), status: project.status.clone(), }; let created = projects.create(user_id, new_project).await?; project_id_map.insert(project.id, created.id); result.projects_restored += 1; } } // Import tasks, remapping project_id references and restoring subtasks/annotations let mut task_id_map: HashMap = HashMap::new(); for task in &input.tasks { if tasks.get_by_id(task.id, user_id).await?.is_none() { let new_task = crate::models::NewTask::builder(&task.description) .priority(task.priority.clone()) .tags(task.tags.clone()) .recurrence(task.recurrence.clone()) .urgency(task.urgency); let new_task = if let Some(due) = task.due { new_task.due(due) } else { new_task }; let remapped_pid = task.project_id .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid))); let new_task = if let Some(pid) = remapped_pid { new_task.project_id(pid) } else { new_task }; let created = tasks.create(user_id, new_task.build()).await?; task_id_map.insert(task.id, created.id); result.tasks_restored += 1; // Restore annotations for annotation in &task.annotations { if tasks.add_annotation(created.id, user_id, &annotation.note).await?.is_some() { result.annotations_restored += 1; } } // Restore subtasks (text-only; linked subtasks handled in second pass) for subtask in &task.subtasks { if subtask.linked_task_id.is_none() { if tasks.add_subtask(created.id, user_id, &subtask.text).await?.is_some() { result.subtasks_restored += 1; } } } } } // Second pass: restore subtasks with linked_task_id (requires all tasks to exist) for task in &input.tasks { let new_parent_id = task_id_map.get(&task.id).copied().unwrap_or(task.id); for subtask in &task.subtasks { if let Some(linked_id) = subtask.linked_task_id { let new_linked_id = task_id_map.get(&linked_id).copied().unwrap_or(linked_id); if tasks.add_subtask_link(new_parent_id, user_id, new_linked_id).await.ok().flatten().is_some() { result.subtasks_restored += 1; } } } } // Import events, remapping project_id references for event in &input.events { if events.get_by_id(event.id, user_id).await?.is_none() { let new_event = NewEvent::builder(&event.title, event.start_time) .description(&event.description) .recurrence(event.recurrence.clone()); let new_event = if let Some(end) = event.end_time { new_event.end_time(end) } else { new_event }; let new_event = if let Some(ref loc) = event.location { new_event.location(loc) } else { new_event }; let remapped_pid = event.project_id .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid))); let new_event = if let Some(pid) = remapped_pid { new_event.project_id(pid) } else { new_event }; events.create(user_id, new_event.build()).await?; result.events_restored += 1; } } // Import emails for email in &input.emails { let exists = if let Some(ref msg_id) = email.message_id { emails.exists_by_message_id(user_id, msg_id).await? } else { emails.get_by_id(email.id, user_id).await?.is_some() }; if !exists { let new_email = NewEmail { project_id: email.project_id, from_address: email.from.clone(), to_address: email.to.clone(), subject: email.subject.clone(), body: email.body.clone(), is_read: email.is_read, received_at: Some(email.received_at), }; emails.create(user_id, new_email).await?; result.emails_restored += 1; } } // Import contacts for contact in &input.contacts { if contacts.get_by_id(contact.id, user_id).await?.is_none() { let new_contact = NewContact { display_name: contact.display_name.clone(), nickname: contact.nickname.clone(), company: contact.company.clone(), title: contact.title.clone(), notes: contact.notes.clone(), tags: contact.tags.clone(), birthday: contact.birthday, timezone: contact.timezone.clone(), is_implicit: contact.is_implicit, }; let created = contacts.create(user_id, new_contact).await?; result.contacts_restored += 1; // Restore sub-collections for email in &contact.emails { let _ = contacts.add_email(created.id, user_id, NewContactEmail { address: email.address.clone(), label: email.label.clone(), is_primary: email.is_primary, }).await; } for phone in &contact.phones { let _ = contacts.add_phone(created.id, user_id, NewContactPhone { number: phone.number.clone(), label: phone.label.clone(), is_primary: phone.is_primary, }).await; } for handle in &contact.social_handles { let _ = contacts.add_social_handle(created.id, user_id, NewSocialHandle { platform: handle.platform.clone(), handle: handle.handle.clone(), url: handle.url.clone(), }).await; } for field in &contact.custom_fields { let _ = contacts.add_custom_field(created.id, user_id, NewContactCustomField { label: field.label.clone(), value: field.value.clone(), url: field.url.clone(), }).await; } } } Ok(result) } #[cfg(test)] mod tests { use super::*; #[test] fn restore_result_default_all_zeros() { let r = RestoreResult::default(); assert_eq!(r.projects_restored, 0); assert_eq!(r.tasks_restored, 0); assert_eq!(r.events_restored, 0); assert_eq!(r.emails_restored, 0); } #[test] fn restore_input_accepts_empty_vecs() { let input = RestoreInput { projects: vec![], tasks: vec![], events: vec![], emails: vec![], contacts: vec![], }; assert!(input.projects.is_empty()); assert!(input.tasks.is_empty()); assert!(input.events.is_empty()); assert!(input.emails.is_empty()); assert!(input.contacts.is_empty()); } }