Skip to main content

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}