max / goingson
13 files changed,
+555 insertions,
-90 deletions
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | //! and sub-collections are written verbatim; this module only defines the types that | |
| 7 | 7 | //! cross the command -> storage boundary. | |
| 8 | 8 | ||
| 9 | - | use crate::models::{Email, Event, Project, Task}; | |
| 9 | + | use crate::models::{Attachment, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task, TimeSession}; | |
| 10 | 10 | use crate::Contact; | |
| 11 | 11 | ||
| 12 | 12 | /// Result of a restore operation. | |
| @@ -26,6 +26,16 @@ pub struct RestoreResult { | |||
| 26 | 26 | pub annotations_restored: usize, | |
| 27 | 27 | /// Number of contacts restored. | |
| 28 | 28 | pub contacts_restored: usize, | |
| 29 | + | /// Number of time sessions restored. | |
| 30 | + | pub time_sessions_restored: usize, | |
| 31 | + | /// Number of milestones restored. | |
| 32 | + | pub milestones_restored: usize, | |
| 33 | + | /// Number of daily notes restored. | |
| 34 | + | pub daily_notes_restored: usize, | |
| 35 | + | /// Number of attachment records restored. | |
| 36 | + | pub attachments_restored: usize, | |
| 37 | + | /// Number of sync accounts restored. | |
| 38 | + | pub sync_accounts_restored: usize, | |
| 29 | 39 | } | |
| 30 | 40 | ||
| 31 | 41 | /// Pre-parsed backup data for restoration. | |
| @@ -35,6 +45,11 @@ pub struct RestoreInput { | |||
| 35 | 45 | pub events: Vec<Event>, | |
| 36 | 46 | pub emails: Vec<Email>, | |
| 37 | 47 | pub contacts: Vec<Contact>, | |
| 48 | + | pub time_sessions: Vec<TimeSession>, | |
| 49 | + | pub milestones: Vec<Milestone>, | |
| 50 | + | pub daily_notes: Vec<DailyNote>, | |
| 51 | + | pub attachments: Vec<Attachment>, | |
| 52 | + | pub sync_accounts: Vec<SyncAccount>, | |
| 38 | 53 | } | |
| 39 | 54 | ||
| 40 | 55 | #[cfg(test)] | |
| @@ -58,11 +73,21 @@ mod tests { | |||
| 58 | 73 | events: vec![], | |
| 59 | 74 | emails: vec![], | |
| 60 | 75 | contacts: vec![], | |
| 76 | + | time_sessions: vec![], | |
| 77 | + | milestones: vec![], | |
| 78 | + | daily_notes: vec![], | |
| 79 | + | attachments: vec![], | |
| 80 | + | sync_accounts: vec![], | |
| 61 | 81 | }; | |
| 62 | 82 | assert!(input.projects.is_empty()); | |
| 63 | 83 | assert!(input.tasks.is_empty()); | |
| 64 | 84 | assert!(input.events.is_empty()); | |
| 65 | 85 | assert!(input.emails.is_empty()); | |
| 66 | 86 | assert!(input.contacts.is_empty()); | |
| 87 | + | assert!(input.time_sessions.is_empty()); | |
| 88 | + | assert!(input.milestones.is_empty()); | |
| 89 | + | assert!(input.daily_notes.is_empty()); | |
| 90 | + | assert!(input.attachments.is_empty()); | |
| 91 | + | assert!(input.sync_accounts.is_empty()); | |
| 67 | 92 | } | |
| 68 | 93 | } |
| @@ -222,6 +222,9 @@ pub trait TaskRepository: Send + Sync { | |||
| 222 | 222 | /// Lists all time sessions for a task. | |
| 223 | 223 | async fn list_time_sessions(&self, task_id: TaskId, user_id: UserId) -> Result<Vec<TimeSession>>; | |
| 224 | 224 | ||
| 225 | + | /// Lists every time session for a user across all tasks (for full backup export). | |
| 226 | + | async fn list_all_time_sessions(&self, user_id: UserId) -> Result<Vec<TimeSession>>; | |
| 227 | + | ||
| 225 | 228 | /// Logs a manual time entry (retroactive, no live timer). | |
| 226 | 229 | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession>; | |
| 227 | 230 | ||
| @@ -814,6 +817,9 @@ pub trait DailyNoteRepository: Send + Sync { | |||
| 814 | 817 | ||
| 815 | 818 | /// Creates or updates a daily note (upsert by user_id + date). | |
| 816 | 819 | async fn upsert(&self, user_id: UserId, date: NaiveDate, went_well: &str, could_improve: &str, is_reviewed: bool) -> Result<crate::models::DailyNote>; | |
| 820 | + | ||
| 821 | + | /// Lists every daily note for a user (for full backup export). | |
| 822 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<crate::models::DailyNote>>; | |
| 817 | 823 | } | |
| 818 | 824 | ||
| 819 | 825 | /// Repository for monthly review goals and reflections. | |
| @@ -844,6 +850,9 @@ pub trait MilestoneRepository: Send + Sync { | |||
| 844 | 850 | /// Lists all milestones for a project, ordered by position. | |
| 845 | 851 | async fn list_by_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<crate::models::Milestone>>; | |
| 846 | 852 | ||
| 853 | + | /// Lists every milestone for a user across all projects (for full backup export). | |
| 854 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<crate::models::Milestone>>; | |
| 855 | + | ||
| 847 | 856 | /// Gets a milestone by ID. | |
| 848 | 857 | async fn get_by_id(&self, id: MilestoneId, user_id: UserId) -> Result<Option<crate::models::Milestone>>; | |
| 849 | 858 | ||
| @@ -998,6 +1007,9 @@ pub trait AttachmentRepository: Send + Sync { | |||
| 998 | 1007 | ||
| 999 | 1008 | /// Lists all distinct blob hashes for a user (for blob sync). | |
| 1000 | 1009 | async fn list_all_blob_hashes(&self, user_id: UserId) -> Result<Vec<String>>; | |
| 1010 | + | ||
| 1011 | + | /// Lists every attachment record for a user (for full backup export). | |
| 1012 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<Attachment>>; | |
| 1001 | 1013 | } | |
| 1002 | 1014 | ||
| 1003 | 1015 | /// Repository for sync account CRUD operations. |
| @@ -177,4 +177,19 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 177 | 177 | ||
| 178 | 178 | Ok(rows.into_iter().map(|(h,)| h).collect()) | |
| 179 | 179 | } | |
| 180 | + | ||
| 181 | + | #[tracing::instrument(skip_all)] | |
| 182 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<Attachment>> { | |
| 183 | + | let sql = format!( | |
| 184 | + | "SELECT {} FROM attachments WHERE user_id = ? ORDER BY created_at ASC", | |
| 185 | + | SELECT_COLS | |
| 186 | + | ); | |
| 187 | + | let rows = sqlx::query_as::<_, AttachmentRow>(&sql) | |
| 188 | + | .bind(user_id.to_string()) | |
| 189 | + | .fetch_all(&self.pool) | |
| 190 | + | .await | |
| 191 | + | .map_err(CoreError::database)?; | |
| 192 | + | ||
| 193 | + | rows.into_iter().map(Attachment::try_from).collect() | |
| 194 | + | } | |
| 180 | 195 | } |
| @@ -72,6 +72,22 @@ impl DailyNoteRepository for SqliteDailyNoteRepository { | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | #[tracing::instrument(skip_all)] | |
| 75 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<DailyNote>> { | |
| 76 | + | let rows: Vec<DailyNoteRow> = sqlx::query_as( | |
| 77 | + | "SELECT id, user_id, note_date, went_well, could_improve, is_reviewed, reviewed_at, created_at, updated_at | |
| 78 | + | FROM daily_notes | |
| 79 | + | WHERE user_id = ? | |
| 80 | + | ORDER BY note_date ASC" | |
| 81 | + | ) | |
| 82 | + | .bind(user_id.to_string()) | |
| 83 | + | .fetch_all(&self.pool) | |
| 84 | + | .await | |
| 85 | + | .map_err(CoreError::database)?; | |
| 86 | + | ||
| 87 | + | rows.into_iter().map(DailyNote::try_from).collect() | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[tracing::instrument(skip_all)] | |
| 75 | 91 | async fn upsert( | |
| 76 | 92 | &self, | |
| 77 | 93 | user_id: UserId, |
| @@ -89,6 +89,24 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | #[tracing::instrument(skip_all)] | |
| 92 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<Milestone>> { | |
| 93 | + | let rows = sqlx::query_as::<_, MilestoneRow>( | |
| 94 | + | r#" | |
| 95 | + | SELECT id, user_id, project_id, name, description, position, target_date, status, created_at | |
| 96 | + | FROM milestones | |
| 97 | + | WHERE user_id = ? | |
| 98 | + | ORDER BY created_at ASC | |
| 99 | + | "#, | |
| 100 | + | ) | |
| 101 | + | .bind(user_id.to_string()) | |
| 102 | + | .fetch_all(&self.pool) | |
| 103 | + | .await | |
| 104 | + | .map_err(CoreError::database)?; | |
| 105 | + | ||
| 106 | + | rows.into_iter().map(Milestone::try_from).collect() | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[tracing::instrument(skip_all)] | |
| 92 | 110 | async fn get_by_id(&self, id: MilestoneId, user_id: UserId) -> Result<Option<Milestone>> { | |
| 93 | 111 | let row = sqlx::query_as::<_, MilestoneRow>( | |
| 94 | 112 | r#" |
| @@ -31,11 +31,21 @@ pub async fn restore_all( | |||
| 31 | 31 | let mut tx = pool.begin().await.map_err(CoreError::database)?; | |
| 32 | 32 | let mut result = RestoreResult::default(); | |
| 33 | 33 | ||
| 34 | + | // Ordered so every foreign key's target is inserted first; otherwise an | |
| 35 | + | // INSERT OR IGNORE silently drops a row that references a not-yet-restored | |
| 36 | + | // parent. projects/contacts are roots; milestones and emails are parents of | |
| 37 | + | // tasks; tasks parent time_sessions/events/attachments; daily_notes and | |
| 38 | + | // sync_accounts are independent. | |
| 34 | 39 | restore_projects(&mut tx, user_id, input, &mut result).await?; | |
| 40 | + | restore_contacts_with_children(&mut tx, user_id, input, &mut result).await?; | |
| 41 | + | restore_milestones(&mut tx, user_id, input, &mut result).await?; | |
| 42 | + | restore_emails(&mut tx, user_id, input, &mut result).await?; | |
| 35 | 43 | restore_tasks_with_children(&mut tx, user_id, input, &mut result).await?; | |
| 44 | + | restore_time_sessions(&mut tx, user_id, input, &mut result).await?; | |
| 36 | 45 | restore_events(&mut tx, user_id, input, &mut result).await?; | |
| 37 | - | restore_emails(&mut tx, user_id, input, &mut result).await?; | |
| 38 | - | restore_contacts_with_children(&mut tx, user_id, input, &mut result).await?; | |
| 46 | + | restore_attachments(&mut tx, user_id, input, &mut result).await?; | |
| 47 | + | restore_daily_notes(&mut tx, user_id, input, &mut result).await?; | |
| 48 | + | restore_sync_accounts(&mut tx, user_id, input, &mut result).await?; | |
| 39 | 49 | ||
| 40 | 50 | tx.commit().await.map_err(CoreError::database)?; | |
| 41 | 51 | Ok(result) | |
| @@ -429,3 +439,173 @@ async fn restore_contacts_with_children( | |||
| 429 | 439 | } | |
| 430 | 440 | Ok(()) | |
| 431 | 441 | } | |
| 442 | + | ||
| 443 | + | async fn restore_milestones( | |
| 444 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 445 | + | user_id: UserId, | |
| 446 | + | input: &RestoreInput, | |
| 447 | + | result: &mut RestoreResult, | |
| 448 | + | ) -> Result<()> { | |
| 449 | + | for milestone in &input.milestones { | |
| 450 | + | let target_date = milestone.target_date.map(|d| d.format("%Y-%m-%d").to_string()); | |
| 451 | + | let affected = sqlx::query( | |
| 452 | + | "INSERT OR IGNORE INTO milestones (\ | |
| 453 | + | id, user_id, project_id, name, description, position, target_date, status, created_at\ | |
| 454 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 455 | + | ) | |
| 456 | + | .bind(milestone.id.to_string()) | |
| 457 | + | .bind(user_id.to_string()) | |
| 458 | + | .bind(milestone.project_id.to_string()) | |
| 459 | + | .bind(&milestone.name) | |
| 460 | + | .bind(&milestone.description) | |
| 461 | + | .bind(milestone.position) | |
| 462 | + | .bind(&target_date) | |
| 463 | + | .bind(milestone.status.db_value()) | |
| 464 | + | .bind(format_datetime(&milestone.created_at)) | |
| 465 | + | .execute(&mut **tx) | |
| 466 | + | .await | |
| 467 | + | .map_err(CoreError::database)? | |
| 468 | + | .rows_affected(); | |
| 469 | + | if affected > 0 { | |
| 470 | + | result.milestones_restored += 1; | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | Ok(()) | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | async fn restore_time_sessions( | |
| 477 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 478 | + | user_id: UserId, | |
| 479 | + | input: &RestoreInput, | |
| 480 | + | result: &mut RestoreResult, | |
| 481 | + | ) -> Result<()> { | |
| 482 | + | for session in &input.time_sessions { | |
| 483 | + | let affected = sqlx::query( | |
| 484 | + | "INSERT OR IGNORE INTO time_sessions (\ | |
| 485 | + | id, task_id, user_id, started_at, ended_at, duration_minutes, created_at\ | |
| 486 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 487 | + | ) | |
| 488 | + | .bind(session.id.to_string()) | |
| 489 | + | .bind(session.task_id.to_string()) | |
| 490 | + | .bind(user_id.to_string()) | |
| 491 | + | .bind(format_datetime(&session.started_at)) | |
| 492 | + | .bind(format_datetime_opt(session.ended_at)) | |
| 493 | + | .bind(session.duration_minutes) | |
| 494 | + | .bind(format_datetime(&session.created_at)) | |
| 495 | + | .execute(&mut **tx) | |
| 496 | + | .await | |
| 497 | + | .map_err(CoreError::database)? | |
| 498 | + | .rows_affected(); | |
| 499 | + | if affected > 0 { | |
| 500 | + | result.time_sessions_restored += 1; | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | Ok(()) | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | async fn restore_attachments( | |
| 507 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 508 | + | user_id: UserId, | |
| 509 | + | input: &RestoreInput, | |
| 510 | + | result: &mut RestoreResult, | |
| 511 | + | ) -> Result<()> { | |
| 512 | + | // Row metadata only; the content-addressed blob files are a separate store and | |
| 513 | + | // are re-fetched by blob sync, not embedded in the backup. | |
| 514 | + | for attachment in &input.attachments { | |
| 515 | + | let affected = sqlx::query( | |
| 516 | + | "INSERT OR IGNORE INTO attachments (\ | |
| 517 | + | id, user_id, task_id, project_id, filename, file_size, mime_type, blob_hash, \ | |
| 518 | + | source_email_id, created_at\ | |
| 519 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 520 | + | ) | |
| 521 | + | .bind(attachment.id.to_string()) | |
| 522 | + | .bind(user_id.to_string()) | |
| 523 | + | .bind(attachment.task_id.map(|t| t.to_string())) | |
| 524 | + | .bind(attachment.project_id.map(|p| p.to_string())) | |
| 525 | + | .bind(&attachment.filename) | |
| 526 | + | .bind(attachment.file_size) | |
| 527 | + | .bind(&attachment.mime_type) | |
| 528 | + | .bind(&attachment.blob_hash) | |
| 529 | + | .bind(attachment.source_email_id.map(|e| e.to_string())) | |
| 530 | + | .bind(format_datetime(&attachment.created_at)) | |
| 531 | + | .execute(&mut **tx) | |
| 532 | + | .await | |
| 533 | + | .map_err(CoreError::database)? | |
| 534 | + | .rows_affected(); | |
| 535 | + | if affected > 0 { | |
| 536 | + | result.attachments_restored += 1; | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | Ok(()) | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | async fn restore_daily_notes( | |
| 543 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 544 | + | user_id: UserId, | |
| 545 | + | input: &RestoreInput, | |
| 546 | + | result: &mut RestoreResult, | |
| 547 | + | ) -> Result<()> { | |
| 548 | + | for note in &input.daily_notes { | |
| 549 | + | let affected = sqlx::query( | |
| 550 | + | "INSERT OR IGNORE INTO daily_notes (\ | |
| 551 | + | id, user_id, note_date, went_well, could_improve, is_reviewed, reviewed_at, \ | |
| 552 | + | created_at, updated_at\ | |
| 553 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 554 | + | ) | |
| 555 | + | .bind(note.id.to_string()) | |
| 556 | + | .bind(user_id.to_string()) | |
| 557 | + | .bind(note.note_date.format("%Y-%m-%d").to_string()) | |
| 558 | + | .bind(¬e.went_well) | |
| 559 | + | .bind(¬e.could_improve) | |
| 560 | + | .bind(if note.is_reviewed { 1 } else { 0 }) | |
| 561 | + | .bind(format_datetime_opt(note.reviewed_at)) | |
| 562 | + | .bind(format_datetime(¬e.created_at)) | |
| 563 | + | .bind(format_datetime(¬e.updated_at)) | |
| 564 | + | .execute(&mut **tx) | |
| 565 | + | .await | |
| 566 | + | .map_err(CoreError::database)? | |
| 567 | + | .rows_affected(); | |
| 568 | + | if affected > 0 { | |
| 569 | + | result.daily_notes_restored += 1; | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | Ok(()) | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | async fn restore_sync_accounts( | |
| 576 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 577 | + | user_id: UserId, | |
| 578 | + | input: &RestoreInput, | |
| 579 | + | result: &mut RestoreResult, | |
| 580 | + | ) -> Result<()> { | |
| 581 | + | // The sync-transient last_*_sync timestamps are intentionally omitted (they are | |
| 582 | + | // re-derived on the next sync), matching the synced-column set. | |
| 583 | + | for account in &input.sync_accounts { | |
| 584 | + | let calendar_ids = serde_json::to_string(&account.calendar_ids).unwrap_or_else(|_| "[]".to_string()); | |
| 585 | + | let affected = sqlx::query( | |
| 586 | + | "INSERT OR IGNORE INTO sync_accounts (\ | |
| 587 | + | id, user_id, provider, account_name, email, sync_calendars, sync_contacts, \ | |
| 588 | + | calendar_ids, sync_interval_minutes, enabled, created_at\ | |
| 589 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 590 | + | ) | |
| 591 | + | .bind(account.id.to_string()) | |
| 592 | + | .bind(user_id.to_string()) | |
| 593 | + | .bind(&account.provider) | |
| 594 | + | .bind(&account.account_name) | |
| 595 | + | .bind(&account.email) | |
| 596 | + | .bind(if account.sync_calendars { 1 } else { 0 }) | |
| 597 | + | .bind(if account.sync_contacts { 1 } else { 0 }) | |
| 598 | + | .bind(&calendar_ids) | |
| 599 | + | .bind(account.sync_interval_minutes) | |
| 600 | + | .bind(if account.enabled { 1 } else { 0 }) | |
| 601 | + | .bind(format_datetime(&account.created_at)) | |
| 602 | + | .execute(&mut **tx) | |
| 603 | + | .await | |
| 604 | + | .map_err(CoreError::database)? | |
| 605 | + | .rows_affected(); | |
| 606 | + | if affected > 0 { | |
| 607 | + | result.sync_accounts_restored += 1; | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | Ok(()) | |
| 611 | + | } |
| @@ -921,6 +921,11 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 921 | 921 | } | |
| 922 | 922 | ||
| 923 | 923 | #[tracing::instrument(skip_all)] | |
| 924 | + | async fn list_all_time_sessions(&self, user_id: UserId) -> Result<Vec<TimeSession>> { | |
| 925 | + | time_session_repo::list_all_time_sessions(&self.pool, user_id).await | |
| 926 | + | } | |
| 927 | + | ||
| 928 | + | #[tracing::instrument(skip_all)] | |
| 924 | 929 | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession> { | |
| 925 | 930 | time_session_repo::log_manual_time(&self.pool, task_id, user_id, minutes, date).await | |
| 926 | 931 | } |
| @@ -275,6 +275,24 @@ pub(crate) async fn list_time_sessions( | |||
| 275 | 275 | rows.into_iter().map(|r| r.into_session()).collect() | |
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | + | /// List every time session for a user across all tasks (for full backup export). | |
| 279 | + | pub(crate) async fn list_all_time_sessions( | |
| 280 | + | pool: &SqlitePool, | |
| 281 | + | user_id: UserId, | |
| 282 | + | ) -> Result<Vec<TimeSession>> { | |
| 283 | + | let rows = sqlx::query_as::<_, TimeSessionRow>( | |
| 284 | + | "SELECT id, task_id, user_id, started_at, ended_at, duration_minutes, created_at | |
| 285 | + | FROM time_sessions WHERE user_id = ? | |
| 286 | + | ORDER BY started_at ASC" | |
| 287 | + | ) | |
| 288 | + | .bind(user_id.to_string()) | |
| 289 | + | .fetch_all(pool) | |
| 290 | + | .await | |
| 291 | + | .map_err(CoreError::database)?; | |
| 292 | + | ||
| 293 | + | rows.into_iter().map(|r| r.into_session()).collect() | |
| 294 | + | } | |
| 295 | + | ||
| 278 | 296 | /// Get aggregated time tracking summary grouped by project and date. | |
| 279 | 297 | pub(crate) async fn get_time_summary( | |
| 280 | 298 | pool: &SqlitePool, |
| @@ -10,10 +10,14 @@ mod common; | |||
| 10 | 10 | ||
| 11 | 11 | use goingson_core::backup_restore::RestoreInput; | |
| 12 | 12 | use goingson_core::{ | |
| 13 | - | EventRepository, NewEvent, NewProject, NewTask, ProjectRepository, TaskRepository, TaskStatus, | |
| 13 | + | AttachmentRepository, DailyNoteRepository, EventRepository, MilestoneRepository, NewAttachment, | |
| 14 | + | NewEvent, NewMilestone, NewProject, NewTask, ProjectRepository, SyncAccountRepository, | |
| 15 | + | TaskRepository, TaskStatus, | |
| 14 | 16 | }; | |
| 15 | 17 | use goingson_db_sqlite::{ | |
| 16 | - | restore_all, SqliteEventRepository, SqliteProjectRepository, SqliteTaskRepository, | |
| 18 | + | restore_all, SqliteAttachmentRepository, SqliteDailyNoteRepository, SqliteEventRepository, | |
| 19 | + | SqliteMilestoneRepository, SqliteProjectRepository, SqliteSyncAccountRepository, | |
| 20 | + | SqliteTaskRepository, | |
| 17 | 21 | }; | |
| 18 | 22 | ||
| 19 | 23 | #[tokio::test] | |
| @@ -66,6 +70,11 @@ async fn restore_preserves_completed_status_and_is_idempotent() { | |||
| 66 | 70 | events: vec![], | |
| 67 | 71 | emails: vec![], | |
| 68 | 72 | contacts: vec![], | |
| 73 | + | time_sessions: vec![], | |
| 74 | + | milestones: vec![], | |
| 75 | + | daily_notes: vec![], | |
| 76 | + | attachments: vec![], | |
| 77 | + | sync_accounts: vec![], | |
| 69 | 78 | }; | |
| 70 | 79 | ||
| 71 | 80 | // First restore recreates with status + completed_at + id preserved. | |
| @@ -167,6 +176,11 @@ async fn restore_preserves_subtask_completion_ordering_and_annotation_timestamp( | |||
| 167 | 176 | events: vec![], | |
| 168 | 177 | emails: vec![], | |
| 169 | 178 | contacts: vec![], | |
| 179 | + | time_sessions: vec![], | |
| 180 | + | milestones: vec![], | |
| 181 | + | daily_notes: vec![], | |
| 182 | + | attachments: vec![], | |
| 183 | + | sync_accounts: vec![], | |
| 170 | 184 | }; | |
| 171 | 185 | let r = restore_all(&pool, user_id, &input).await.unwrap(); | |
| 172 | 186 | assert_eq!(r.tasks_restored, 1); | |
| @@ -255,6 +269,11 @@ async fn restore_event_preserves_external_metadata() { | |||
| 255 | 269 | events: vec![full.clone()], | |
| 256 | 270 | emails: vec![], | |
| 257 | 271 | contacts: vec![], | |
| 272 | + | time_sessions: vec![], | |
| 273 | + | milestones: vec![], | |
| 274 | + | daily_notes: vec![], | |
| 275 | + | attachments: vec![], | |
| 276 | + | sync_accounts: vec![], | |
| 258 | 277 | }; | |
| 259 | 278 | let r = restore_all(&pool, user_id, &input).await.unwrap(); | |
| 260 | 279 | assert_eq!(r.events_restored, 1); | |
| @@ -277,3 +296,143 @@ async fn restore_event_preserves_external_metadata() { | |||
| 277 | 296 | ); | |
| 278 | 297 | assert!(restored.is_read_only, "is_read_only must survive restore"); | |
| 279 | 298 | } | |
| 299 | + | ||
| 300 | + | #[tokio::test] | |
| 301 | + | async fn restore_round_trips_supplemental_collections() { | |
| 302 | + | let pool = common::setup_test_db().await; | |
| 303 | + | let user = common::create_test_user(&pool).await; | |
| 304 | + | ||
| 305 | + | let projects = SqliteProjectRepository::new(pool.clone()); | |
| 306 | + | let tasks = SqliteTaskRepository::new(pool.clone()); | |
| 307 | + | let milestones = SqliteMilestoneRepository::new(pool.clone()); | |
| 308 | + | let attachments = SqliteAttachmentRepository::new(pool.clone()); | |
| 309 | + | let daily_notes = SqliteDailyNoteRepository::new(pool.clone()); | |
| 310 | + | let sync_accounts = SqliteSyncAccountRepository::new(pool.clone()); | |
| 311 | + | ||
| 312 | + | let project = projects | |
| 313 | + | .create( | |
| 314 | + | user, | |
| 315 | + | NewProject { | |
| 316 | + | name: "P".into(), | |
| 317 | + | description: String::new(), | |
| 318 | + | project_type: Default::default(), | |
| 319 | + | status: Default::default(), | |
| 320 | + | }, | |
| 321 | + | ) | |
| 322 | + | .await | |
| 323 | + | .unwrap(); | |
| 324 | + | let task = tasks | |
| 325 | + | .create(user, NewTask::builder("T").build()) | |
| 326 | + | .await | |
| 327 | + | .unwrap(); | |
| 328 | + | ||
| 329 | + | let milestone = milestones | |
| 330 | + | .create( | |
| 331 | + | user, | |
| 332 | + | NewMilestone { | |
| 333 | + | project_id: project.id, | |
| 334 | + | name: "M1".into(), | |
| 335 | + | description: "desc".into(), | |
| 336 | + | position: 0, | |
| 337 | + | target_date: chrono::NaiveDate::from_ymd_opt(2024, 12, 31), | |
| 338 | + | }, | |
| 339 | + | ) | |
| 340 | + | .await | |
| 341 | + | .unwrap(); | |
| 342 | + | let session = tasks | |
| 343 | + | .log_manual_time(task.id, user, 45, chrono::Utc::now()) | |
| 344 | + | .await | |
| 345 | + | .unwrap(); | |
| 346 | + | let note = daily_notes | |
| 347 | + | .upsert( | |
| 348 | + | user, | |
| 349 | + | chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), | |
| 350 | + | "went well", | |
| 351 | + | "could improve", | |
| 352 | + | true, | |
| 353 | + | ) | |
| 354 | + | .await | |
| 355 | + | .unwrap(); | |
| 356 | + | let attachment = attachments | |
| 357 | + | .create( | |
| 358 | + | user, | |
| 359 | + | NewAttachment { | |
| 360 | + | task_id: Some(task.id), | |
| 361 | + | project_id: None, | |
| 362 | + | filename: "f.pdf".into(), | |
| 363 | + | file_size: 1234, | |
| 364 | + | mime_type: "application/pdf".into(), | |
| 365 | + | blob_hash: "deadbeef".into(), | |
| 366 | + | source_email_id: None, | |
| 367 | + | }, | |
| 368 | + | ) | |
| 369 | + | .await | |
| 370 | + | .unwrap(); | |
| 371 | + | let account = sync_accounts | |
| 372 | + | .create(user, "google", "My Calendar", Some("me@example.com")) | |
| 373 | + | .await | |
| 374 | + | .unwrap(); | |
| 375 | + | ||
| 376 | + | // The backup payload, then wipe everything (CASCADE handles the children). | |
| 377 | + | let input = RestoreInput { | |
| 378 | + | projects: vec![project.clone()], | |
| 379 | + | tasks: vec![tasks.get_by_id(task.id, user).await.unwrap().unwrap()], | |
| 380 | + | events: vec![], | |
| 381 | + | emails: vec![], | |
| 382 | + | contacts: vec![], | |
| 383 | + | time_sessions: vec![session.clone()], | |
| 384 | + | milestones: vec![milestone.clone()], | |
| 385 | + | daily_notes: vec![note.clone()], | |
| 386 | + | attachments: vec![attachment.clone()], | |
| 387 | + | sync_accounts: vec![account.clone()], | |
| 388 | + | }; | |
| 389 | + | // Deleting tasks cascades time_sessions + attachments; deleting projects | |
| 390 | + | // cascades milestones; daily_notes and sync_accounts are independent. | |
| 391 | + | for table in ["tasks", "daily_notes", "sync_accounts", "projects"] { | |
| 392 | + | sqlx::query(&format!("DELETE FROM {table}")) | |
| 393 | + | .execute(&pool) | |
| 394 | + | .await | |
| 395 | + | .unwrap(); | |
| 396 | + | } | |
| 397 | + | assert!(milestones.list_all(user).await.unwrap().is_empty()); | |
| 398 | + | assert!(tasks.list_all_time_sessions(user).await.unwrap().is_empty()); | |
| 399 | + | assert!(attachments.list_all(user).await.unwrap().is_empty()); | |
| 400 | + | ||
| 401 | + | let r = restore_all(&pool, user, &input).await.unwrap(); | |
| 402 | + | assert_eq!(r.milestones_restored, 1); | |
| 403 | + | assert_eq!(r.time_sessions_restored, 1); | |
| 404 | + | assert_eq!(r.daily_notes_restored, 1); | |
| 405 | + | assert_eq!(r.attachments_restored, 1); | |
| 406 | + | assert_eq!(r.sync_accounts_restored, 1); | |
| 407 | + | ||
| 408 | + | let ms = milestones.list_all(user).await.unwrap(); | |
| 409 | + | assert_eq!(ms.len(), 1); | |
| 410 | + | assert_eq!(ms[0].id, milestone.id); | |
| 411 | + | assert_eq!(ms[0].target_date, milestone.target_date, "target_date preserved"); | |
| 412 | + | ||
| 413 | + | let ts = tasks.list_all_time_sessions(user).await.unwrap(); | |
| 414 | + | assert_eq!(ts.len(), 1); | |
| 415 | + | assert_eq!(ts[0].id, session.id); | |
| 416 | + | assert_eq!(ts[0].duration_minutes, session.duration_minutes, "minutes preserved"); | |
| 417 | + | ||
| 418 | + | let dn = daily_notes.list_all(user).await.unwrap(); | |
| 419 | + | assert_eq!(dn.len(), 1); | |
| 420 | + | assert!(dn[0].is_reviewed, "is_reviewed preserved"); | |
| 421 | + | ||
| 422 | + | let at = attachments.list_all(user).await.unwrap(); | |
| 423 | + | assert_eq!(at.len(), 1); | |
| 424 | + | assert_eq!(at[0].blob_hash, "deadbeef", "blob_hash preserved"); | |
| 425 | + | assert_eq!(at[0].file_size, 1234, "file_size preserved"); | |
| 426 | + | ||
| 427 | + | let sa = sync_accounts.list_all(user).await.unwrap(); | |
| 428 | + | assert_eq!(sa.len(), 1); | |
| 429 | + | assert_eq!(sa[0].provider, "google"); | |
| 430 | + | assert_eq!(sa[0].email.as_deref(), Some("me@example.com")); | |
| 431 | + | ||
| 432 | + | // Idempotent re-restore. | |
| 433 | + | let r2 = restore_all(&pool, user, &input).await.unwrap(); | |
| 434 | + | assert_eq!(r2.milestones_restored, 0); | |
| 435 | + | assert_eq!(r2.time_sessions_restored, 0); | |
| 436 | + | assert_eq!(r2.attachments_restored, 0); | |
| 437 | + | assert_eq!(r2.sync_accounts_restored, 0); | |
| 438 | + | } |
| @@ -31,6 +31,39 @@ fn backup_filename(now: chrono::DateTime<Utc>) -> String { | |||
| 31 | 31 | format!("goingson-backup-{stamp}-{suffix}.json.gz") | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | /// Collect every syncable collection into a `FullExport`. | |
| 35 | + | /// | |
| 36 | + | /// Single source of truth for what a backup contains, so the auto-backup, manual | |
| 37 | + | /// backup, and JSON-export paths cannot drift in which tables they capture (the | |
| 38 | + | /// drift behind the ultra-fuzz finding that auto-backup silently omitted | |
| 39 | + | /// time_sessions, milestones, daily_notes, attachments, and sync_accounts). | |
| 40 | + | pub(crate) async fn collect_full_export( | |
| 41 | + | state: &AppState, | |
| 42 | + | ) -> Result<FullExport, goingson_core::CoreError> { | |
| 43 | + | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 44 | + | let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; | |
| 45 | + | let events = state.events.list_all(DESKTOP_USER_ID).await?; | |
| 46 | + | let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?; | |
| 47 | + | let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?; | |
| 48 | + | let time_sessions = state.tasks.list_all_time_sessions(DESKTOP_USER_ID).await?; | |
| 49 | + | let milestones = state.milestones.list_all(DESKTOP_USER_ID).await?; | |
| 50 | + | let daily_notes = state.daily_notes.list_all(DESKTOP_USER_ID).await?; | |
| 51 | + | let attachments = state.attachments.list_all(DESKTOP_USER_ID).await?; | |
| 52 | + | let sync_accounts = state.sync_accounts.list_all(DESKTOP_USER_ID).await?; | |
| 53 | + | Ok(FullExport::new( | |
| 54 | + | projects, | |
| 55 | + | tasks, | |
| 56 | + | events, | |
| 57 | + | emails, | |
| 58 | + | contacts, | |
| 59 | + | time_sessions, | |
| 60 | + | milestones, | |
| 61 | + | daily_notes, | |
| 62 | + | attachments, | |
| 63 | + | sync_accounts, | |
| 64 | + | )) | |
| 65 | + | } | |
| 66 | + | ||
| 34 | 67 | /// Starts the background backup scheduler that creates automatic backups | |
| 35 | 68 | /// based on user settings and prunes old backups. | |
| 36 | 69 | pub async fn start_backup_scheduler(app: tauri::AppHandle, cancel: CancellationToken) { | |
| @@ -125,34 +158,7 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu | |||
| 125 | 158 | let filename = backup_filename(now); | |
| 126 | 159 | let file_path = backup_dir.join(&filename); | |
| 127 | 160 | ||
| 128 | - | // Fetch all data | |
| 129 | - | let projects = state | |
| 130 | - | .projects | |
| 131 | - | .list_all(DESKTOP_USER_ID) | |
| 132 | - | .await | |
| 133 | - | .map_err(|e| e.to_string())?; | |
| 134 | - | let tasks = state | |
| 135 | - | .tasks | |
| 136 | - | .list_all(DESKTOP_USER_ID) | |
| 137 | - | .await | |
| 138 | - | .map_err(|e| e.to_string())?; | |
| 139 | - | let events = state | |
| 140 | - | .events | |
| 141 | - | .list_all(DESKTOP_USER_ID) | |
| 142 | - | .await | |
| 143 | - | .map_err(|e| e.to_string())?; | |
| 144 | - | let emails = state | |
| 145 | - | .emails | |
| 146 | - | .list_all(DESKTOP_USER_ID, true) | |
| 147 | - | .await | |
| 148 | - | .map_err(|e| e.to_string())?; | |
| 149 | - | let contacts = state | |
| 150 | - | .contacts | |
| 151 | - | .list_all(DESKTOP_USER_ID) | |
| 152 | - | .await | |
| 153 | - | .map_err(|e| e.to_string())?; | |
| 154 | - | ||
| 155 | - | let export = FullExport::new(projects, tasks, events, emails, contacts); | |
| 161 | + | let export = collect_full_export(state).await.map_err(|e| e.to_string())?; | |
| 156 | 162 | let size = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; | |
| 157 | 163 | ||
| 158 | 164 | info!( | |
| @@ -237,34 +243,7 @@ pub async fn create_backup_now( | |||
| 237 | 243 | let filename = backup_filename(now); | |
| 238 | 244 | let file_path = backup_dir.join(&filename); | |
| 239 | 245 | ||
| 240 | - | // Fetch all data | |
| 241 | - | let projects = state | |
| 242 | - | .projects | |
| 243 | - | .list_all(DESKTOP_USER_ID) | |
| 244 | - | .await | |
| 245 | - | .map_err(|e| e.to_string())?; | |
| 246 | - | let tasks = state | |
| 247 | - | .tasks | |
| 248 | - | .list_all(DESKTOP_USER_ID) | |
| 249 | - | .await | |
| 250 | - | .map_err(|e| e.to_string())?; | |
| 251 | - | let events = state | |
| 252 | - | .events | |
| 253 | - | .list_all(DESKTOP_USER_ID) | |
| 254 | - | .await | |
| 255 | - | .map_err(|e| e.to_string())?; | |
| 256 | - | let emails = state | |
| 257 | - | .emails | |
| 258 | - | .list_all(DESKTOP_USER_ID, true) | |
| 259 | - | .await | |
| 260 | - | .map_err(|e| e.to_string())?; | |
| 261 | - | let contacts = state | |
| 262 | - | .contacts | |
| 263 | - | .list_all(DESKTOP_USER_ID) | |
| 264 | - | .await | |
| 265 | - | .map_err(|e| e.to_string())?; | |
| 266 | - | ||
| 267 | - | let export = FullExport::new(projects, tasks, events, emails, contacts); | |
| 246 | + | let export = collect_full_export(state).await.map_err(|e| e.to_string())?; | |
| 268 | 247 | let item_count = export.total_count(); | |
| 269 | 248 | let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; | |
| 270 | 249 |
| @@ -139,14 +139,7 @@ pub async fn export_json( | |||
| 139 | 139 | ) -> Result<ExportResponse, ApiError> { | |
| 140 | 140 | validate_export_path(&file_path)?; | |
| 141 | 141 | ||
| 142 | - | // Fetch all data | |
| 143 | - | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 144 | - | let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; | |
| 145 | - | let events = state.events.list_all(DESKTOP_USER_ID).await?; | |
| 146 | - | let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?; | |
| 147 | - | let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?; | |
| 148 | - | ||
| 149 | - | let export = backup::FullExport::new(projects, tasks, events, emails, contacts); | |
| 142 | + | let export = crate::backup_scheduler::collect_full_export(&state).await?; | |
| 150 | 143 | let item_count = export.total_count(); | |
| 151 | 144 | ||
| 152 | 145 | let size_bytes = backup::write_json(&export, &file_path) | |
| @@ -267,14 +260,7 @@ pub async fn create_backup( | |||
| 267 | 260 | let filename = format!("goingson-backup-{}.json.gz", timestamp); | |
| 268 | 261 | let file_path = backup_dir.join(&filename); | |
| 269 | 262 | ||
| 270 | - | // Fetch all data | |
| 271 | - | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 272 | - | let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; | |
| 273 | - | let events = state.events.list_all(DESKTOP_USER_ID).await?; | |
| 274 | - | let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?; | |
| 275 | - | let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?; | |
| 276 | - | ||
| 277 | - | let export = backup::FullExport::new(projects, tasks, events, emails, contacts); | |
| 263 | + | let export = crate::backup_scheduler::collect_full_export(&state).await?; | |
| 278 | 264 | let item_count = export.total_count(); | |
| 279 | 265 | ||
| 280 | 266 | let size_bytes = backup::write_backup(&export, &file_path) | |
| @@ -383,13 +369,17 @@ pub async fn restore_backup( | |||
| 383 | 369 | ||
| 384 | 370 | let backup_created_at = export.exported_at.to_rfc3339(); | |
| 385 | 371 | ||
| 386 | - | // Delegate restore orchestration to core | |
| 387 | 372 | let input = goingson_core::backup_restore::RestoreInput { | |
| 388 | 373 | projects: export.projects, | |
| 389 | 374 | tasks: export.tasks, | |
| 390 | 375 | events: export.events, | |
| 391 | 376 | emails: export.emails, | |
| 392 | 377 | contacts: export.contacts, | |
| 378 | + | time_sessions: export.time_sessions, | |
| 379 | + | milestones: export.milestones, | |
| 380 | + | daily_notes: export.daily_notes, | |
| 381 | + | attachments: export.attachments, | |
| 382 | + | sync_accounts: export.sync_accounts, | |
| 393 | 383 | }; | |
| 394 | 384 | ||
| 395 | 385 | // Single all-or-nothing transaction over the shared pool: a mid-restore failure |
| @@ -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, vec![]); | |
| 48 | + | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], 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, vec![]); | |
| 72 | + | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], 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, vec![]); | |
| 226 | + | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 227 | 227 | assert_eq!(export.total_count(), 3); | |
| 228 | 228 | ||
| 229 | 229 | let dir = tempdir().unwrap(); |
| @@ -12,9 +12,19 @@ use flate2::write::GzEncoder; | |||
| 12 | 12 | use flate2::Compression; | |
| 13 | 13 | use serde::{Deserialize, Serialize}; | |
| 14 | 14 | ||
| 15 | - | use goingson_core::{Contact, Email, Event, Project, Task}; | |
| 15 | + | use goingson_core::{ | |
| 16 | + | Attachment, Contact, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task, | |
| 17 | + | TimeSession, | |
| 18 | + | }; | |
| 16 | 19 | ||
| 17 | 20 | /// Full export of all GoingsOn data. | |
| 21 | + | /// | |
| 22 | + | /// The supplemental collections (`time_sessions`, `milestones`, `daily_notes`, | |
| 23 | + | /// `attachments`, `sync_accounts`) are `#[serde(default)]` so backups written by | |
| 24 | + | /// older versions (which omitted them) still deserialize; on restore they simply | |
| 25 | + | /// come back empty. `attachments` carries the row metadata only -- the | |
| 26 | + | /// content-addressed blob files live in a separate store and are re-fetched by | |
| 27 | + | /// blob sync, not embedded in the JSON backup. | |
| 18 | 28 | #[derive(Debug, Serialize, Deserialize)] | |
| 19 | 29 | #[serde(rename_all = "camelCase")] | |
| 20 | 30 | pub struct FullExport { | |
| @@ -33,19 +43,43 @@ pub struct FullExport { | |||
| 33 | 43 | /// All contacts. | |
| 34 | 44 | #[serde(default)] | |
| 35 | 45 | pub contacts: Vec<Contact>, | |
| 46 | + | /// All time-tracking sessions. | |
| 47 | + | #[serde(default)] | |
| 48 | + | pub time_sessions: Vec<TimeSession>, | |
| 49 | + | /// All milestones. | |
| 50 | + | #[serde(default)] | |
| 51 | + | pub milestones: Vec<Milestone>, | |
| 52 | + | /// All daily notes. | |
| 53 | + | #[serde(default)] | |
| 54 | + | pub daily_notes: Vec<DailyNote>, | |
| 55 | + | /// All attachment records (blob metadata only; blob files are not embedded). | |
| 56 | + | #[serde(default)] | |
| 57 | + | pub attachments: Vec<Attachment>, | |
| 58 | + | /// All calendar/contact sync accounts. | |
| 59 | + | #[serde(default)] | |
| 60 | + | pub sync_accounts: Vec<SyncAccount>, | |
| 36 | 61 | } | |
| 37 | 62 | ||
| 38 | 63 | impl FullExport { | |
| 39 | 64 | /// Current export format version. | |
| 40 | - | pub const CURRENT_VERSION: &'static str = "1.1"; | |
| 65 | + | pub const CURRENT_VERSION: &'static str = "1.2"; | |
| 41 | 66 | ||
| 42 | 67 | /// Creates a new full export with the current timestamp. | |
| 68 | + | /// | |
| 69 | + | /// Takes every syncable collection so a backup is complete by construction; | |
| 70 | + | /// pass empty vecs for collections a caller does not have. | |
| 71 | + | #[allow(clippy::too_many_arguments)] | |
| 43 | 72 | pub fn new( | |
| 44 | 73 | projects: Vec<Project>, | |
| 45 | 74 | tasks: Vec<Task>, | |
| 46 | 75 | events: Vec<Event>, | |
| 47 | 76 | emails: Vec<Email>, | |
| 48 | 77 | contacts: Vec<Contact>, | |
| 78 | + | time_sessions: Vec<TimeSession>, | |
| 79 | + | milestones: Vec<Milestone>, | |
| 80 | + | daily_notes: Vec<DailyNote>, | |
| 81 | + | attachments: Vec<Attachment>, | |
| 82 | + | sync_accounts: Vec<SyncAccount>, | |
| 49 | 83 | ) -> Self { | |
| 50 | 84 | Self { | |
| 51 | 85 | version: Self::CURRENT_VERSION.to_string(), | |
| @@ -55,12 +89,26 @@ impl FullExport { | |||
| 55 | 89 | events, | |
| 56 | 90 | emails, | |
| 57 | 91 | contacts, | |
| 92 | + | time_sessions, | |
| 93 | + | milestones, | |
| 94 | + | daily_notes, | |
| 95 | + | attachments, | |
| 96 | + | sync_accounts, | |
| 58 | 97 | } | |
| 59 | 98 | } | |
| 60 | 99 | ||
| 61 | 100 | /// Returns the total count of all items in the export. | |
| 62 | 101 | pub fn total_count(&self) -> usize { | |
| 63 | - | self.projects.len() + self.tasks.len() + self.events.len() + self.emails.len() + self.contacts.len() | |
| 102 | + | self.projects.len() | |
| 103 | + | + self.tasks.len() | |
| 104 | + | + self.events.len() | |
| 105 | + | + self.emails.len() | |
| 106 | + | + self.contacts.len() | |
| 107 | + | + self.time_sessions.len() | |
| 108 | + | + self.milestones.len() | |
| 109 | + | + self.daily_notes.len() | |
| 110 | + | + self.attachments.len() | |
| 111 | + | + self.sync_accounts.len() | |
| 64 | 112 | } | |
| 65 | 113 | ||
| 66 | 114 | /// Checks if this export version is compatible with the current version. | |
| @@ -205,20 +253,20 @@ mod tests { | |||
| 205 | 253 | ||
| 206 | 254 | #[test] | |
| 207 | 255 | fn test_full_export_total_count() { | |
| 208 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); | |
| 256 | + | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 209 | 257 | assert_eq!(export.total_count(), 0); | |
| 210 | 258 | } | |
| 211 | 259 | ||
| 212 | 260 | #[test] | |
| 213 | 261 | fn test_full_export_is_compatible() { | |
| 214 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); | |
| 262 | + | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 215 | 263 | assert!(export.is_compatible()); | |
| 216 | 264 | ||
| 217 | - | let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); | |
| 265 | + | let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 218 | 266 | old_export.version = "1.5".to_string(); | |
| 219 | 267 | assert!(old_export.is_compatible()); | |
| 220 | 268 | ||
| 221 | - | let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); | |
| 269 | + | let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 222 | 270 | future_export.version = "2.0".to_string(); | |
| 223 | 271 | assert!(!future_export.is_compatible()); | |
| 224 | 272 | } | |
| @@ -228,7 +276,7 @@ mod tests { | |||
| 228 | 276 | let dir = tempdir().unwrap(); | |
| 229 | 277 | let backup_path = dir.path().join("test.json.gz"); | |
| 230 | 278 | ||
| 231 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); | |
| 279 | + | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 232 | 280 | write_backup(&export, &backup_path).unwrap(); | |
| 233 | 281 | ||
| 234 | 282 | let restored = read_backup(&backup_path).unwrap(); | |
| @@ -241,7 +289,7 @@ mod tests { | |||
| 241 | 289 | let dir = tempdir().unwrap(); | |
| 242 | 290 | let json_path = dir.path().join("test.json"); | |
| 243 | 291 | ||
| 244 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); | |
| 292 | + | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 245 | 293 | let size = write_json(&export, &json_path).unwrap(); | |
| 246 | 294 | assert!(size > 0); | |
| 247 | 295 |