Skip to main content

max / goingson

10.2 KB · 338 lines History Blame Raw
1 //! Backup and restore utilities.
2 //!
3 //! Provides compressed JSON backup creation and restoration for all GoingsOn data.
4
5 use std::fs::File;
6 use std::io::{BufWriter, Read, Write};
7 use std::path::Path;
8
9 use chrono::{DateTime, Utc};
10 use flate2::read::GzDecoder;
11 use flate2::write::GzEncoder;
12 use flate2::Compression;
13 use serde::{Deserialize, Serialize};
14
15 use goingson_core::{
16 Attachment, Contact, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection,
17 Project, SavedView, SyncAccount, Task, TimeSession, WeeklyReview,
18 };
19
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.
28 #[derive(Debug, Serialize, Deserialize)]
29 #[serde(rename_all = "camelCase")]
30 pub struct FullExport {
31 /// Export format version for compatibility checking.
32 pub version: String,
33 /// When the export was created.
34 pub exported_at: DateTime<Utc>,
35 /// All projects.
36 pub projects: Vec<Project>,
37 /// All tasks (including subtasks and annotations).
38 pub tasks: Vec<Task>,
39 /// All events.
40 pub events: Vec<Event>,
41 /// All emails.
42 pub emails: Vec<Email>,
43 /// All contacts.
44 #[serde(default)]
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>,
61 /// All saved views / filters.
62 #[serde(default)]
63 pub saved_views: Vec<SavedView>,
64 /// All weekly review rows (notes + vacation days).
65 #[serde(default)]
66 pub weekly_reviews: Vec<WeeklyReview>,
67 /// All monthly goals.
68 #[serde(default)]
69 pub monthly_goals: Vec<MonthlyGoal>,
70 /// All monthly reflections.
71 #[serde(default)]
72 pub monthly_reflections: Vec<MonthlyReflection>,
73 }
74
75 impl FullExport {
76 /// Current export format version.
77 pub const CURRENT_VERSION: &'static str = "1.3";
78
79 /// Creates a new full export with the current timestamp.
80 ///
81 /// Takes every syncable collection so a backup is complete by construction;
82 /// pass empty vecs for collections a caller does not have.
83 #[allow(clippy::too_many_arguments)]
84 pub fn new(
85 projects: Vec<Project>,
86 tasks: Vec<Task>,
87 events: Vec<Event>,
88 emails: Vec<Email>,
89 contacts: Vec<Contact>,
90 time_sessions: Vec<TimeSession>,
91 milestones: Vec<Milestone>,
92 daily_notes: Vec<DailyNote>,
93 attachments: Vec<Attachment>,
94 sync_accounts: Vec<SyncAccount>,
95 saved_views: Vec<SavedView>,
96 weekly_reviews: Vec<WeeklyReview>,
97 monthly_goals: Vec<MonthlyGoal>,
98 monthly_reflections: Vec<MonthlyReflection>,
99 ) -> Self {
100 Self {
101 version: Self::CURRENT_VERSION.to_string(),
102 exported_at: Utc::now(),
103 projects,
104 tasks,
105 events,
106 emails,
107 contacts,
108 time_sessions,
109 milestones,
110 daily_notes,
111 attachments,
112 sync_accounts,
113 saved_views,
114 weekly_reviews,
115 monthly_goals,
116 monthly_reflections,
117 }
118 }
119
120 /// Returns the total count of all items in the export.
121 pub fn total_count(&self) -> usize {
122 self.projects.len()
123 + self.tasks.len()
124 + self.events.len()
125 + self.emails.len()
126 + self.contacts.len()
127 + self.time_sessions.len()
128 + self.milestones.len()
129 + self.daily_notes.len()
130 + self.attachments.len()
131 + self.sync_accounts.len()
132 + self.saved_views.len()
133 + self.weekly_reviews.len()
134 + self.monthly_goals.len()
135 + self.monthly_reflections.len()
136 }
137
138 /// Checks if this export version is compatible with the current version.
139 pub fn is_compatible(&self) -> bool {
140 // For now, only version 1.x is supported
141 self.version.starts_with("1.")
142 }
143 }
144
145 /// Writes a full export to a gzip-compressed JSON file.
146 ///
147 /// # Arguments
148 ///
149 /// * `export` - The data to export
150 /// * `path` - Destination file path
151 ///
152 /// # Returns
153 ///
154 /// The size of the compressed file in bytes.
155 pub fn write_backup<P: AsRef<Path>>(export: &FullExport, path: P) -> Result<u64, BackupError> {
156 let dest = path.as_ref();
157 let tmp_path = dest.with_extension("tmp");
158
159 let file = File::create(&tmp_path)?;
160 let mut encoder = GzEncoder::new(file, Compression::default());
161
162 // Serialize straight into the compressor so we never hold the full
163 // serialized JSON in memory alongside the already-in-memory export.
164 serde_json::to_writer(&mut encoder, export)?;
165 encoder.finish()?;
166
167 // Prevents corrupt backups if the process crashes mid-write
168 std::fs::rename(&tmp_path, dest)?;
169
170 let metadata = std::fs::metadata(dest)?;
171 Ok(metadata.len())
172 }
173
174 /// Reads a full export from a gzip-compressed JSON file.
175 ///
176 /// # Arguments
177 ///
178 /// * `path` - Source file path
179 ///
180 /// # Returns
181 ///
182 /// The parsed export data.
183 pub fn read_backup<P: AsRef<Path>>(path: P) -> Result<FullExport, BackupError> {
184 let file = File::open(path.as_ref())?;
185 let decoder = GzDecoder::new(file);
186 // Limit decompressed size to 500 MB to prevent decompression bombs
187 let mut limited = decoder.take(500 * 1024 * 1024);
188
189 let mut json = String::new();
190 limited.read_to_string(&mut json)?;
191
192 let export: FullExport = serde_json::from_str(&json)?;
193
194 if !export.is_compatible() {
195 return Err(BackupError::IncompatibleVersion {
196 found: export.version,
197 expected: FullExport::CURRENT_VERSION.to_string(),
198 });
199 }
200
201 Ok(export)
202 }
203
204 /// Writes a full export to uncompressed JSON.
205 ///
206 /// # Arguments
207 ///
208 /// * `export` - The data to export
209 /// * `path` - Destination file path
210 ///
211 /// # Returns
212 ///
213 /// The size of the file in bytes.
214 pub fn write_json<P: AsRef<Path>>(export: &FullExport, path: P) -> Result<u64, BackupError> {
215 let dest = path.as_ref();
216 let tmp = dest.with_extension("json.tmp");
217 // Stream the serialized JSON directly to the file through a buffered
218 // writer instead of building the whole pretty-printed string in memory.
219 let mut writer = BufWriter::new(File::create(&tmp)?);
220 serde_json::to_writer_pretty(&mut writer, export)?;
221 writer.flush()?;
222 std::fs::rename(&tmp, dest)?;
223
224 let metadata = std::fs::metadata(dest)?;
225 Ok(metadata.len())
226 }
227
228 /// Error type for backup operations.
229 #[derive(Debug)]
230 pub enum BackupError {
231 /// IO error (file not found, permission denied, etc.)
232 Io(std::io::Error),
233 /// JSON serialization/deserialization error
234 Json(serde_json::Error),
235 /// Backup version is not compatible
236 IncompatibleVersion { found: String, expected: String },
237 }
238
239 impl std::fmt::Display for BackupError {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 match self {
242 BackupError::Io(e) => write!(f, "IO error: {}", e),
243 BackupError::Json(e) => write!(f, "JSON error: {}", e),
244 BackupError::IncompatibleVersion { found, expected } => {
245 write!(
246 f,
247 "Incompatible backup version: found {}, expected {}",
248 found, expected
249 )
250 }
251 }
252 }
253 }
254
255 impl std::error::Error for BackupError {
256 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
257 match self {
258 BackupError::Io(e) => Some(e),
259 BackupError::Json(e) => Some(e),
260 BackupError::IncompatibleVersion { .. } => None,
261 }
262 }
263 }
264
265 impl From<std::io::Error> for BackupError {
266 fn from(err: std::io::Error) -> Self {
267 BackupError::Io(err)
268 }
269 }
270
271 impl From<serde_json::Error> for BackupError {
272 fn from(err: serde_json::Error) -> Self {
273 BackupError::Json(err)
274 }
275 }
276
277 #[cfg(test)]
278 mod tests {
279 use super::*;
280 use tempfile::tempdir;
281
282 /// An empty export with every collection vec defaulted -- keeps the tests from
283 /// re-listing the positional collection arguments.
284 fn empty_export() -> FullExport {
285 FullExport::new(
286 vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![],
287 vec![], vec![], vec![],
288 )
289 }
290
291 #[test]
292 fn test_full_export_total_count() {
293 let export = empty_export();
294 assert_eq!(export.total_count(), 0);
295 }
296
297 #[test]
298 fn test_full_export_is_compatible() {
299 let export = empty_export();
300 assert!(export.is_compatible());
301
302 let mut old_export = empty_export();
303 old_export.version = "1.5".to_string();
304 assert!(old_export.is_compatible());
305
306 let mut future_export = empty_export();
307 future_export.version = "2.0".to_string();
308 assert!(!future_export.is_compatible());
309 }
310
311 #[test]
312 fn test_backup_round_trip() {
313 let dir = tempdir().unwrap();
314 let backup_path = dir.path().join("test.json.gz");
315
316 let export = empty_export();
317 write_backup(&export, &backup_path).unwrap();
318
319 let restored = read_backup(&backup_path).unwrap();
320 assert_eq!(restored.version, export.version);
321 assert_eq!(restored.total_count(), 0);
322 }
323
324 #[test]
325 fn test_json_export() {
326 let dir = tempdir().unwrap();
327 let json_path = dir.path().join("test.json");
328
329 let export = empty_export();
330 let size = write_json(&export, &json_path).unwrap();
331 assert!(size > 0);
332
333 let content = std::fs::read_to_string(&json_path).unwrap();
334 assert!(content.contains("\"version\""));
335 assert!(content.contains("\"projects\""));
336 }
337 }
338