ankit_engine/backup.rs
1//! Backup and restore workflows for Anki decks.
2//!
3//! This module provides high-level operations for backing up and restoring
4//! Anki decks to/from .apkg files.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use ankit_engine::Engine;
10//!
11//! # async fn example() -> ankit_engine::Result<()> {
12//! let engine = Engine::new();
13//!
14//! // Backup a deck
15//! let result = engine.backup()
16//! .backup_deck("Japanese", "/tmp/backups")
17//! .await?;
18//! println!("Backed up to: {}", result.path.display());
19//!
20//! // Restore from backup
21//! let result = engine.backup()
22//! .restore_deck(&result.path)
23//! .await?;
24//! println!("Restored: {}", if result.success { "yes" } else { "no" });
25//! # Ok(())
26//! # }
27//! ```
28
29use crate::{Error, Result};
30use ankit::AnkiClient;
31use std::path::{Path, PathBuf};
32
33/// Engine for backup and restore operations.
34pub struct BackupEngine<'a> {
35 client: &'a AnkiClient,
36}
37
38impl<'a> BackupEngine<'a> {
39 pub(crate) fn new(client: &'a AnkiClient) -> Self {
40 Self { client }
41 }
42
43 /// Backup a deck to an .apkg file.
44 ///
45 /// Creates a backup file in the specified directory with a timestamped filename.
46 /// The backup includes all notes, cards, scheduling data, and media.
47 ///
48 /// # Arguments
49 ///
50 /// * `deck` - Name of the deck to backup
51 /// * `backup_dir` - Directory where the backup file will be created
52 ///
53 /// # Returns
54 ///
55 /// Returns [`BackupResult`] with the path to the created backup file.
56 ///
57 /// # Example
58 ///
59 /// ```no_run
60 /// use ankit_engine::Engine;
61 ///
62 /// # async fn example() -> ankit_engine::Result<()> {
63 /// let engine = Engine::new();
64 /// let result = engine.backup()
65 /// .backup_deck("Japanese::Vocabulary", "/home/user/anki-backups")
66 /// .await?;
67 /// println!("Backup created: {}", result.path.display());
68 /// println!("Size: {} bytes", result.size_bytes);
69 /// # Ok(())
70 /// # }
71 /// ```
72 pub async fn backup_deck(
73 &self,
74 deck: &str,
75 backup_dir: impl AsRef<Path>,
76 ) -> Result<BackupResult> {
77 self.backup_deck_with_options(deck, backup_dir, BackupOptions::default())
78 .await
79 }
80
81 /// Backup a deck with custom options.
82 ///
83 /// # Arguments
84 ///
85 /// * `deck` - Name of the deck to backup
86 /// * `backup_dir` - Directory where the backup file will be created
87 /// * `options` - Backup options (scheduling data, filename format)
88 ///
89 /// # Example
90 ///
91 /// ```no_run
92 /// use ankit_engine::Engine;
93 /// use ankit_engine::backup::BackupOptions;
94 ///
95 /// # async fn example() -> ankit_engine::Result<()> {
96 /// let engine = Engine::new();
97 /// let options = BackupOptions {
98 /// include_scheduling: false, // Don't include review history
99 /// ..Default::default()
100 /// };
101 /// let result = engine.backup()
102 /// .backup_deck_with_options("Japanese", "/tmp/backups", options)
103 /// .await?;
104 /// # Ok(())
105 /// # }
106 /// ```
107 pub async fn backup_deck_with_options(
108 &self,
109 deck: &str,
110 backup_dir: impl AsRef<Path>,
111 options: BackupOptions,
112 ) -> Result<BackupResult> {
113 let backup_dir = backup_dir.as_ref();
114
115 // Ensure backup directory exists
116 if !backup_dir.exists() {
117 std::fs::create_dir_all(backup_dir).map_err(|e| {
118 Error::Backup(format!(
119 "Failed to create backup directory '{}': {}",
120 backup_dir.display(),
121 e
122 ))
123 })?;
124 }
125
126 // Generate filename with timestamp
127 let timestamp = chrono_lite_timestamp();
128 let safe_deck_name = sanitize_filename(deck);
129 let filename = format!("{}-{}.apkg", safe_deck_name, timestamp);
130 let backup_path = backup_dir.join(&filename);
131
132 // Export the deck
133 let path_str = backup_path.to_string_lossy();
134 self.client
135 .misc()
136 .export_package(deck, &path_str, Some(options.include_scheduling))
137 .await
138 .map_err(|e| Error::Backup(format!("Failed to export deck '{}': {}", deck, e)))?;
139
140 // Get file size
141 let size_bytes = std::fs::metadata(&backup_path)
142 .map(|m| m.len())
143 .unwrap_or(0);
144
145 Ok(BackupResult {
146 path: backup_path,
147 deck_name: deck.to_string(),
148 size_bytes,
149 include_scheduling: options.include_scheduling,
150 })
151 }
152
153 /// Restore a deck from an .apkg backup file.
154 ///
155 /// Imports the backup file into Anki. If the deck already exists,
156 /// Anki's default duplicate handling will apply.
157 ///
158 /// # Arguments
159 ///
160 /// * `backup_path` - Path to the .apkg file to restore
161 ///
162 /// # Example
163 ///
164 /// ```no_run
165 /// use ankit_engine::Engine;
166 ///
167 /// # async fn example() -> ankit_engine::Result<()> {
168 /// let engine = Engine::new();
169 /// let result = engine.backup()
170 /// .restore_deck("/home/user/backups/Japanese-2024-01-15.apkg")
171 /// .await?;
172 /// if result.success {
173 /// println!("Restore completed successfully");
174 /// }
175 /// # Ok(())
176 /// # }
177 /// ```
178 pub async fn restore_deck(&self, backup_path: impl AsRef<Path>) -> Result<RestoreResult> {
179 let backup_path = backup_path.as_ref();
180
181 if !backup_path.exists() {
182 return Err(Error::Backup(format!(
183 "Backup file not found: {}",
184 backup_path.display()
185 )));
186 }
187
188 let path_str = backup_path.to_string_lossy();
189 let success = self
190 .client
191 .misc()
192 .import_package(&path_str)
193 .await
194 .map_err(|e| Error::Backup(format!("Failed to import backup: {}", e)))?;
195
196 Ok(RestoreResult {
197 path: backup_path.to_path_buf(),
198 success,
199 })
200 }
201
202 /// Backup all decks to separate .apkg files.
203 ///
204 /// Creates individual backup files for each deck in the collection.
205 ///
206 /// # Arguments
207 ///
208 /// * `backup_dir` - Directory where backup files will be created
209 ///
210 /// # Returns
211 ///
212 /// Returns a [`CollectionBackupResult`] with results for each deck.
213 ///
214 /// # Example
215 ///
216 /// ```no_run
217 /// use ankit_engine::Engine;
218 ///
219 /// # async fn example() -> ankit_engine::Result<()> {
220 /// let engine = Engine::new();
221 /// let result = engine.backup()
222 /// .backup_collection("/home/user/anki-backups")
223 /// .await?;
224 /// println!("Backed up {} decks", result.successful.len());
225 /// if !result.failed.is_empty() {
226 /// println!("Failed: {:?}", result.failed);
227 /// }
228 /// # Ok(())
229 /// # }
230 /// ```
231 pub async fn backup_collection(
232 &self,
233 backup_dir: impl AsRef<Path>,
234 ) -> Result<CollectionBackupResult> {
235 let backup_dir = backup_dir.as_ref();
236
237 // Create a timestamped subdirectory for this backup
238 let timestamp = chrono_lite_timestamp();
239 let collection_dir = backup_dir.join(format!("collection-{}", timestamp));
240 std::fs::create_dir_all(&collection_dir).map_err(|e| {
241 Error::Backup(format!(
242 "Failed to create backup directory '{}': {}",
243 collection_dir.display(),
244 e
245 ))
246 })?;
247
248 // Get all deck names
249 let decks = self
250 .client
251 .decks()
252 .names()
253 .await
254 .map_err(|e| Error::Backup(format!("Failed to list decks: {}", e)))?;
255
256 let mut successful = Vec::new();
257 let mut failed = Vec::new();
258
259 for deck in decks {
260 // Skip the Default deck if it's empty (common case)
261 match self.backup_deck(&deck, &collection_dir).await {
262 Ok(result) => successful.push(result),
263 Err(e) => failed.push((deck, e.to_string())),
264 }
265 }
266
267 Ok(CollectionBackupResult {
268 backup_dir: collection_dir,
269 successful,
270 failed,
271 })
272 }
273
274 /// List backup files in a directory.
275 ///
276 /// Scans the directory for .apkg files and returns information about each.
277 ///
278 /// # Arguments
279 ///
280 /// * `backup_dir` - Directory to scan for backup files
281 ///
282 /// # Example
283 ///
284 /// ```no_run
285 /// use ankit_engine::Engine;
286 ///
287 /// # async fn example() -> ankit_engine::Result<()> {
288 /// let engine = Engine::new();
289 /// let backups = engine.backup()
290 /// .list_backups("/home/user/anki-backups")
291 /// .await?;
292 /// for backup in backups {
293 /// println!("{}: {} bytes", backup.path.display(), backup.size_bytes);
294 /// }
295 /// # Ok(())
296 /// # }
297 /// ```
298 pub async fn list_backups(&self, backup_dir: impl AsRef<Path>) -> Result<Vec<BackupInfo>> {
299 let backup_dir = backup_dir.as_ref();
300
301 if !backup_dir.exists() {
302 return Ok(Vec::new());
303 }
304
305 let mut backups = Vec::new();
306
307 // Recursively find all .apkg files
308 collect_apkg_files(backup_dir, &mut backups)?;
309
310 // Sort by modification time (newest first)
311 backups.sort_by(|a, b| b.modified.cmp(&a.modified));
312
313 Ok(backups)
314 }
315
316 /// Delete old backups, keeping the most recent N.
317 ///
318 /// Useful for implementing backup rotation.
319 ///
320 /// # Arguments
321 ///
322 /// * `backup_dir` - Directory containing backup files
323 /// * `keep` - Number of most recent backups to keep
324 ///
325 /// # Returns
326 ///
327 /// Returns the paths of deleted backup files.
328 ///
329 /// # Example
330 ///
331 /// ```no_run
332 /// use ankit_engine::Engine;
333 ///
334 /// # async fn example() -> ankit_engine::Result<()> {
335 /// let engine = Engine::new();
336 /// // Keep only the 5 most recent backups
337 /// let deleted = engine.backup()
338 /// .rotate_backups("/home/user/anki-backups", 5)
339 /// .await?;
340 /// println!("Deleted {} old backups", deleted.len());
341 /// # Ok(())
342 /// # }
343 /// ```
344 pub async fn rotate_backups(
345 &self,
346 backup_dir: impl AsRef<Path>,
347 keep: usize,
348 ) -> Result<Vec<PathBuf>> {
349 let backups = self.list_backups(&backup_dir).await?;
350
351 if backups.len() <= keep {
352 return Ok(Vec::new());
353 }
354
355 let mut deleted = Vec::new();
356 for backup in backups.into_iter().skip(keep) {
357 if std::fs::remove_file(&backup.path).is_ok() {
358 deleted.push(backup.path);
359 }
360 }
361
362 Ok(deleted)
363 }
364}
365
366/// Options for backup operations.
367#[derive(Debug, Clone)]
368pub struct BackupOptions {
369 /// Include scheduling data (review history, due dates).
370 /// Default: true
371 pub include_scheduling: bool,
372}
373
374impl Default for BackupOptions {
375 fn default() -> Self {
376 Self {
377 include_scheduling: true,
378 }
379 }
380}
381
382/// Result of a deck backup operation.
383#[derive(Debug, Clone)]
384pub struct BackupResult {
385 /// Path to the created backup file.
386 pub path: PathBuf,
387 /// Name of the backed up deck.
388 pub deck_name: String,
389 /// Size of the backup file in bytes.
390 pub size_bytes: u64,
391 /// Whether scheduling data was included.
392 pub include_scheduling: bool,
393}
394
395/// Result of a restore operation.
396#[derive(Debug, Clone)]
397pub struct RestoreResult {
398 /// Path to the restored backup file.
399 pub path: PathBuf,
400 /// Whether the restore was successful.
401 pub success: bool,
402}
403
404/// Result of a collection backup operation.
405#[derive(Debug, Clone)]
406pub struct CollectionBackupResult {
407 /// Directory containing the backup files.
408 pub backup_dir: PathBuf,
409 /// Successfully backed up decks.
410 pub successful: Vec<BackupResult>,
411 /// Decks that failed to backup (deck name, error message).
412 pub failed: Vec<(String, String)>,
413}
414
415/// Information about a backup file.
416#[derive(Debug, Clone)]
417pub struct BackupInfo {
418 /// Path to the backup file.
419 pub path: PathBuf,
420 /// Size in bytes.
421 pub size_bytes: u64,
422 /// Last modification time (Unix timestamp).
423 pub modified: u64,
424}
425
426/// Generate a simple timestamp without external dependencies.
427fn chrono_lite_timestamp() -> String {
428 use std::time::{SystemTime, UNIX_EPOCH};
429
430 let now = SystemTime::now()
431 .duration_since(UNIX_EPOCH)
432 .unwrap_or_default();
433 let secs = now.as_secs();
434
435 // Convert to date components (simplified, doesn't handle leap seconds)
436 let days = secs / 86400;
437 let remaining = secs % 86400;
438 let hours = remaining / 3600;
439 let minutes = (remaining % 3600) / 60;
440 let seconds = remaining % 60;
441
442 // Calculate year, month, day from days since epoch (1970-01-01)
443 let (year, month, day) = days_to_ymd(days);
444
445 format!(
446 "{:04}{:02}{:02}-{:02}{:02}{:02}",
447 year, month, day, hours, minutes, seconds
448 )
449}
450
451/// Convert days since Unix epoch to year, month, day.
452fn days_to_ymd(days: u64) -> (u64, u64, u64) {
453 // Simplified calculation - accurate enough for backup timestamps
454 let mut remaining_days = days as i64;
455 let mut year = 1970i64;
456
457 loop {
458 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
459 if remaining_days < days_in_year {
460 break;
461 }
462 remaining_days -= days_in_year;
463 year += 1;
464 }
465
466 let days_in_months: [i64; 12] = if is_leap_year(year) {
467 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
468 } else {
469 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
470 };
471
472 let mut month = 1i64;
473 for days_in_month in days_in_months.iter() {
474 if remaining_days < *days_in_month {
475 break;
476 }
477 remaining_days -= days_in_month;
478 month += 1;
479 }
480
481 let day = remaining_days + 1;
482
483 (year as u64, month as u64, day as u64)
484}
485
486fn is_leap_year(year: i64) -> bool {
487 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
488}
489
490/// Sanitize a deck name for use as a filename.
491fn sanitize_filename(name: &str) -> String {
492 name.chars()
493 .map(|c| match c {
494 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
495 _ => c,
496 })
497 .collect()
498}
499
500/// Recursively collect .apkg files from a directory.
501fn collect_apkg_files(dir: &Path, results: &mut Vec<BackupInfo>) -> Result<()> {
502 let entries = std::fs::read_dir(dir).map_err(|e| {
503 Error::Backup(format!(
504 "Failed to read directory '{}': {}",
505 dir.display(),
506 e
507 ))
508 })?;
509
510 for entry in entries.flatten() {
511 let path = entry.path();
512 if path.is_dir() {
513 collect_apkg_files(&path, results)?;
514 } else if path.extension().map(|e| e == "apkg").unwrap_or(false) {
515 if let Ok(metadata) = std::fs::metadata(&path) {
516 let modified = metadata
517 .modified()
518 .ok()
519 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
520 .map(|d| d.as_secs())
521 .unwrap_or(0);
522
523 results.push(BackupInfo {
524 path,
525 size_bytes: metadata.len(),
526 modified,
527 });
528 }
529 }
530 }
531
532 Ok(())
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn test_sanitize_filename() {
541 assert_eq!(sanitize_filename("Japanese"), "Japanese");
542 assert_eq!(sanitize_filename("Japanese::Vocab"), "Japanese__Vocab");
543 assert_eq!(sanitize_filename("Test/Deck"), "Test_Deck");
544 assert_eq!(sanitize_filename("A:B*C?D"), "A_B_C_D");
545 }
546
547 #[test]
548 fn test_chrono_lite_timestamp() {
549 let ts = chrono_lite_timestamp();
550 // Should be 15 characters: YYYYMMDD-HHMMSS
551 assert_eq!(ts.len(), 15);
552 assert!(ts.chars().nth(8) == Some('-'));
553 }
554
555 #[test]
556 fn test_days_to_ymd() {
557 // 1970-01-01
558 assert_eq!(days_to_ymd(0), (1970, 1, 1));
559 // 2000-01-01 (10957 days from epoch)
560 assert_eq!(days_to_ymd(10957), (2000, 1, 1));
561 // 2024-01-01 (19723 days from epoch)
562 assert_eq!(days_to_ymd(19723), (2024, 1, 1));
563 }
564
565 #[test]
566 fn test_is_leap_year() {
567 assert!(!is_leap_year(1970));
568 assert!(is_leap_year(2000));
569 assert!(!is_leap_year(1900));
570 assert!(is_leap_year(2024));
571 }
572}