Skip to main content

max / goingson

9.7 KB · 274 lines History Blame Raw
1 //! Backup restore orchestration logic.
2 //!
3 //! Contains the entity iteration and existence-check-before-create pattern
4 //! for restoring data from backups. The command layer handles file I/O;
5 //! this module handles the restore logic using repository trait objects.
6
7 use std::collections::HashMap;
8
9 use crate::id_types::{ProjectId, TaskId, UserId};
10
11 use crate::error::CoreError;
12 use crate::models::{
13 Email, Event, NewEmail, NewEvent, NewProject, Project, Task,
14 };
15 use crate::{
16 Contact, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle,
17 };
18 use crate::repository::{ContactRepository, EmailRepository, EventRepository, ProjectRepository, TaskRepository};
19
20 /// Result of a restore operation.
21 #[derive(Debug, Default)]
22 pub struct RestoreResult {
23 /// Number of projects restored.
24 pub projects_restored: usize,
25 /// Number of tasks restored.
26 pub tasks_restored: usize,
27 /// Number of events restored.
28 pub events_restored: usize,
29 /// Number of emails restored.
30 pub emails_restored: usize,
31 /// Number of subtasks restored.
32 pub subtasks_restored: usize,
33 /// Number of annotations restored.
34 pub annotations_restored: usize,
35 /// Number of contacts restored.
36 pub contacts_restored: usize,
37 }
38
39 /// Pre-parsed backup data for restoration.
40 pub struct RestoreInput {
41 pub projects: Vec<Project>,
42 pub tasks: Vec<Task>,
43 pub events: Vec<Event>,
44 pub emails: Vec<Email>,
45 pub contacts: Vec<Contact>,
46 }
47
48 /// Restores entities from backup data, skipping those that already exist.
49 ///
50 /// Uses existence checks (by ID or message_id) to avoid duplicates.
51 /// This is a merge operation — existing data is preserved.
52 pub async fn restore_from_backup(
53 user_id: UserId,
54 input: &RestoreInput,
55 projects: &dyn ProjectRepository,
56 tasks: &dyn TaskRepository,
57 events: &dyn EventRepository,
58 emails: &dyn EmailRepository,
59 contacts: &dyn ContactRepository,
60 ) -> Result<RestoreResult, CoreError> {
61 let mut result = RestoreResult::default();
62
63 // Import projects, tracking old-to-new ID mapping
64 let mut project_id_map: HashMap<ProjectId, ProjectId> = HashMap::new();
65 for project in &input.projects {
66 if projects.get_by_id(project.id, user_id).await?.is_none() {
67 let new_project = NewProject {
68 name: project.name.clone(),
69 description: project.description.clone(),
70 project_type: project.project_type.clone(),
71 status: project.status.clone(),
72 };
73 let created = projects.create(user_id, new_project).await?;
74 project_id_map.insert(project.id, created.id);
75 result.projects_restored += 1;
76 }
77 }
78
79 // Import tasks, remapping project_id references and restoring subtasks/annotations
80 let mut task_id_map: HashMap<TaskId, TaskId> = HashMap::new();
81 for task in &input.tasks {
82 if tasks.get_by_id(task.id, user_id).await?.is_none() {
83 let new_task = crate::models::NewTask::builder(&task.description)
84 .priority(task.priority.clone())
85 .tags(task.tags.clone())
86 .recurrence(task.recurrence.clone())
87 .urgency(task.urgency);
88
89 let new_task = if let Some(due) = task.due {
90 new_task.due(due)
91 } else {
92 new_task
93 };
94
95 let remapped_pid = task.project_id
96 .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid)));
97 let new_task = if let Some(pid) = remapped_pid {
98 new_task.project_id(pid)
99 } else {
100 new_task
101 };
102
103 let created = tasks.create(user_id, new_task.build()).await?;
104 task_id_map.insert(task.id, created.id);
105 result.tasks_restored += 1;
106
107 // Restore annotations
108 for annotation in &task.annotations {
109 if tasks.add_annotation(created.id, user_id, &annotation.note).await?.is_some() {
110 result.annotations_restored += 1;
111 }
112 }
113
114 // Restore subtasks (text-only; linked subtasks handled in second pass)
115 for subtask in &task.subtasks {
116 if subtask.linked_task_id.is_none() {
117 if tasks.add_subtask(created.id, user_id, &subtask.text).await?.is_some() {
118 result.subtasks_restored += 1;
119 }
120 }
121 }
122 }
123 }
124
125 // Second pass: restore subtasks with linked_task_id (requires all tasks to exist)
126 for task in &input.tasks {
127 let new_parent_id = task_id_map.get(&task.id).copied().unwrap_or(task.id);
128 for subtask in &task.subtasks {
129 if let Some(linked_id) = subtask.linked_task_id {
130 let new_linked_id = task_id_map.get(&linked_id).copied().unwrap_or(linked_id);
131 if tasks.add_subtask_link(new_parent_id, user_id, new_linked_id).await.ok().flatten().is_some() {
132 result.subtasks_restored += 1;
133 }
134 }
135 }
136 }
137
138 // Import events, remapping project_id references
139 for event in &input.events {
140 if events.get_by_id(event.id, user_id).await?.is_none() {
141 let new_event = NewEvent::builder(&event.title, event.start_time)
142 .description(&event.description)
143 .recurrence(event.recurrence.clone());
144
145 let new_event = if let Some(end) = event.end_time {
146 new_event.end_time(end)
147 } else {
148 new_event
149 };
150
151 let new_event = if let Some(ref loc) = event.location {
152 new_event.location(loc)
153 } else {
154 new_event
155 };
156
157 let remapped_pid = event.project_id
158 .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid)));
159 let new_event = if let Some(pid) = remapped_pid {
160 new_event.project_id(pid)
161 } else {
162 new_event
163 };
164
165 events.create(user_id, new_event.build()).await?;
166 result.events_restored += 1;
167 }
168 }
169
170 // Import emails
171 for email in &input.emails {
172 let exists = if let Some(ref msg_id) = email.message_id {
173 emails.exists_by_message_id(user_id, msg_id).await?
174 } else {
175 emails.get_by_id(email.id, user_id).await?.is_some()
176 };
177
178 if !exists {
179 let new_email = NewEmail {
180 project_id: email.project_id,
181 from_address: email.from.clone(),
182 to_address: email.to.clone(),
183 subject: email.subject.clone(),
184 body: email.body.clone(),
185 is_read: email.is_read,
186 received_at: Some(email.received_at),
187 };
188 emails.create(user_id, new_email).await?;
189 result.emails_restored += 1;
190 }
191 }
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
242 Ok(result)
243 }
244
245 #[cfg(test)]
246 mod tests {
247 use super::*;
248
249 #[test]
250 fn restore_result_default_all_zeros() {
251 let r = RestoreResult::default();
252 assert_eq!(r.projects_restored, 0);
253 assert_eq!(r.tasks_restored, 0);
254 assert_eq!(r.events_restored, 0);
255 assert_eq!(r.emails_restored, 0);
256 }
257
258 #[test]
259 fn restore_input_accepts_empty_vecs() {
260 let input = RestoreInput {
261 projects: vec![],
262 tasks: vec![],
263 events: vec![],
264 emails: vec![],
265 contacts: vec![],
266 };
267 assert!(input.projects.is_empty());
268 assert!(input.tasks.is_empty());
269 assert!(input.events.is_empty());
270 assert!(input.emails.is_empty());
271 assert!(input.contacts.is_empty());
272 }
273 }
274