Skip to main content

falcon_library/
backup.rs

1//! Snapshots of the library `metadata.json` in `backup/`, kept the way Eagle
2//! keeps them, and recovery from them when `metadata.json` is damaged.
3//!
4//! Eagle copies `metadata.json` to `backup/backup-YYYY-MM-DD HH.mm.ss.SSS.json`
5//! (local time) when the library changed and every 30 minutes. It skips the
6//! copy when the newest backup has the same size, refuses to back up a file
7//! that doesn't parse or lacks `applicationVersion`, and keeps at most 100
8//! backups. On open, a `metadata.json` that fails to parse is replaced by the
9//! newest backup that parses and has a `modificationTime`; the damaged file
10//! is kept as `backup/metadata.json.damaged-<ms>`.
11//!
12//! Eagle also keeps one more copy outside the library, in its app data:
13//! `eagle-backup/<hash>.json`, where `<hash>` is the 32-bit FNV-1a hash of
14//! the library path (no trailing slash) over its UTF-16 code units, as 8
15//! lowercase hex digits ([`app_data_backup_name`]). It replaces that copy
16//! whenever it writes a new backup into an existing `backup/` list (not
17//! for the very first one); falcon replaces it with every new backup.
18//! Nothing is written for a library whose folder is gone, so a backup
19//! never recreates a moved or deleted library.
20
21use std::fs;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use chrono::{Local, NaiveDateTime};
26use serde_json::Value;
27
28use crate::error::{Error, Result};
29use crate::library::Library;
30use crate::model::LibraryMetadata;
31
32/// Most backups Eagle keeps; older ones are deleted.
33pub const MAX_BACKUP_COUNT: usize = 100;
34const BACKUP_DIR: &str = "backup";
35const METADATA: &str = "metadata.json";
36/// Eagle only counts files whose name contains this.
37const MARKER: &str = "backup-";
38
39/// The file name Eagle gives a backup taken at `time` (local time), e.g.
40/// `backup-2026-10-26 02.15.25.310.json`.
41pub fn backup_name(time: NaiveDateTime) -> String {
42    format!("{MARKER}{}.json", time.format("%Y-%m-%d %H.%M.%S%.3f"))
43}
44
45impl Library {
46    /// Copies `metadata.json` into `backup/` as Eagle does and returns the
47    /// new backup's path. Returns `None` without writing when the file on
48    /// disk is damaged (doesn't parse or has no `applicationVersion`) or the
49    /// newest backup has the same size. Backups beyond the newest
50    /// [`MAX_BACKUP_COUNT`] are deleted.
51    pub fn backup_metadata(&self) -> Result<Option<PathBuf>> {
52        backup_library(self.root())
53    }
54
55    /// Backup files in `backup/`, newest first (by name, as Eagle sorts).
56    pub fn backups(&self) -> Result<Vec<PathBuf>> {
57        list(&self.root().join(BACKUP_DIR))
58    }
59
60    /// Replaces `metadata.json` with the backup at `path` and reloads the
61    /// folder tree from it. The current file is first kept as
62    /// `backup/metadata.json.damaged-<ms>`. Fails, changing nothing, if the
63    /// backup doesn't parse as a library `metadata.json`.
64    pub fn restore_metadata_from_backup(&mut self, path: impl AsRef<Path>) -> Result<()> {
65        let path = path.as_ref();
66        let metadata = parse_backup(path).ok_or_else(|| Error::Import { path: path.to_path_buf(), reason: "not a usable metadata.json backup".into() })?;
67        replace_metadata(self.root(), path)?;
68        self.metadata = metadata;
69        // The file on disk is now the backup: nothing pending, and undo
70        // starts from here.
71        self.committed.metadata = self.metadata.clone();
72        self.dirty.metadata = false;
73        self.disk_stamp = self.metadata.modification_time;
74        Ok(())
75    }
76
77    /// Opens the library like [`Library::open`], but if `metadata.json`
78    /// can't be read or parsed, restores it from the newest usable backup
79    /// first, as Eagle does on launch. Returns the backup used, if any.
80    pub fn open_or_restore(path: impl AsRef<Path>) -> Result<(Library, Option<PathBuf>)> {
81        Library::open_or_restore_with_progress(path, &|_, _| {})
82    }
83
84    /// [`Library::open_or_restore`] with [`Library::open_with_progress`]'s
85    /// progress calls.
86    pub fn open_or_restore_with_progress(path: impl AsRef<Path>, progress: &dyn Fn(usize, usize)) -> Result<(Library, Option<PathBuf>)> {
87        let root = path.as_ref();
88        let metadata = root.join(METADATA);
89        match Library::open_with_progress(root, progress) {
90            Ok(lib) => Ok((lib, None)),
91            Err(Error::Json { path, .. } | Error::Io { path, .. }) if path == metadata => match restore_newest(root)? {
92                Some(used) => Ok((Library::open_with_progress(root, progress)?, Some(used))),
93                None => Library::open_with_progress(root, progress).map(|lib| (lib, None)),
94            },
95            Err(e) => Err(e),
96        }
97    }
98}
99
100/// The name of the app-data copy of the library at `root`:
101/// `<fnv1a-32 of the path>.json`, as Eagle names it.
102pub fn app_data_backup_name(root: &Path) -> String {
103    let path = root.to_string_lossy();
104    let path = path.trim_end_matches(['/', '\\']);
105    format!("{}.json", fnv1a_hex(path))
106}
107
108/// 32-bit FNV-1a over the UTF-16 code units of `s` (as JavaScript strings
109/// index them), as 8 lowercase hex digits.
110fn fnv1a_hex(s: &str) -> String {
111    let mut h: u32 = 0x811c_9dc5;
112    for unit in s.encode_utf16() {
113        h ^= u32::from(unit);
114        h = h.wrapping_mul(0x0100_0193);
115    }
116    format!("{h:08x}")
117}
118
119/// [`backup_library`], also replacing the library's copy in `app_backups`
120/// (the app's `eagle-backup` folder, created if needed) whenever a new
121/// backup is written. A failure there is ignored, as Eagle ignores it.
122pub fn backup_library_with(root: &Path, app_backups: Option<&Path>) -> Result<Option<PathBuf>> {
123    let made = backup_library(root)?;
124    if let (Some(made), Some(dir)) = (&made, app_backups) {
125        let _ = fs::create_dir_all(dir).and_then(|()| write_atomic(&dir.join(app_data_backup_name(root)), &fs::read(made)?));
126    }
127    Ok(made)
128}
129
130/// Writes `bytes` to `path` through a temporary file beside it and a
131/// rename, so a reader never sees half a file.
132fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
133    let dir = path.parent().unwrap_or(Path::new("."));
134    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
135    std::io::Write::write_all(&mut tmp, bytes)?;
136    tmp.persist(path).map_err(|e| e.error)?;
137    Ok(())
138}
139
140/// [`Library::backup_metadata`] for the library at `root`, without opening
141/// it. It only reads `metadata.json` and writes into `backup/`, so the app
142/// runs it on a background thread while the library stays in use: a
143/// `metadata.json` caught mid-write doesn't parse and is skipped.
144pub fn backup_library(root: &Path) -> Result<Option<PathBuf>> {
145    backup_at(root, Local::now().naive_local())
146}
147
148/// [`Library::backup_metadata`] at a given local time.
149pub(crate) fn backup_at(root: &Path, now: NaiveDateTime) -> Result<Option<PathBuf>> {
150    let current = root.join(METADATA);
151    let dir = root.join(BACKUP_DIR);
152    if !current.is_file() {
153        // The library moved or was deleted: never recreate its folder.
154        return Ok(None);
155    }
156    fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
157
158    let text = fs::read(&current).map_err(|e| Error::io(&current, e))?;
159    let healthy = serde_json::from_slice::<Value>(&text).ok().is_some_and(|v| truthy(v.get("applicationVersion")));
160    if !healthy {
161        return Ok(None);
162    }
163
164    let existing = list(&dir)?;
165    let target = dir.join(backup_name(now));
166    let changed = match existing.first() {
167        None => true,
168        Some(newest) => fs::metadata(newest).map(|m| m.len()).ok() != Some(text.len() as u64),
169    };
170    if changed {
171        write_atomic(&target, &text).map_err(|e| Error::io(&target, e))?;
172    }
173    // Eagle trims the list it read before copying, so with the new copy at
174    // most MAX_BACKUP_COUNT remain.
175    for old in existing.iter().skip(MAX_BACKUP_COUNT - 1) {
176        let _ = fs::remove_file(old);
177    }
178    Ok(changed.then_some(target))
179}
180
181fn list(dir: &Path) -> Result<Vec<PathBuf>> {
182    if !dir.is_dir() {
183        return Ok(Vec::new());
184    }
185    let mut out = Vec::new();
186    for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? {
187        let entry = entry.map_err(|e| Error::io(dir, e))?;
188        if entry.file_name().to_string_lossy().contains(MARKER) && entry.path().is_file() {
189            out.push(entry.path());
190        }
191    }
192    out.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
193    Ok(out)
194}
195
196/// Restores `metadata.json` from the newest usable backup; returns it.
197fn restore_newest(root: &Path) -> Result<Option<PathBuf>> {
198    for candidate in list(&root.join(BACKUP_DIR))? {
199        if fs::metadata(&candidate).is_ok_and(|m| m.len() > 0) && parse_backup(&candidate).is_some() {
200            replace_metadata(root, &candidate)?;
201            return Ok(Some(candidate));
202        }
203    }
204    Ok(None)
205}
206
207/// A backup Eagle would accept (parses, has a `modificationTime`) that
208/// falcon can also load.
209fn parse_backup(path: &Path) -> Option<LibraryMetadata> {
210    let bytes = fs::read(path).ok()?;
211    let value: Value = serde_json::from_slice(&bytes).ok()?;
212    if !truthy(value.get("modificationTime")) {
213        return None;
214    }
215    serde_json::from_value(value).ok()
216}
217
218fn replace_metadata(root: &Path, backup: &Path) -> Result<()> {
219    let current = root.join(METADATA);
220    if current.exists() {
221        let dir = root.join(BACKUP_DIR);
222        fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
223        let ms = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis());
224        let damaged = dir.join(format!("{METADATA}.damaged-{ms}"));
225        let _ = fs::copy(&current, damaged);
226    }
227    fs::copy(backup, &current).map_err(|e| Error::io(&current, e))?;
228    Ok(())
229}
230
231/// JavaScript truthiness for the values Eagle checks.
232fn truthy(v: Option<&Value>) -> bool {
233    match v {
234        None | Some(Value::Null) => false,
235        Some(Value::Bool(b)) => *b,
236        Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0),
237        Some(Value::String(s)) => !s.is_empty(),
238        Some(_) => true,
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use chrono::NaiveDate;
246
247    fn at(h: u32, m: u32, s: u32, ms: u32) -> NaiveDateTime {
248        NaiveDate::from_ymd_opt(2026, 10, 26).unwrap().and_hms_milli_opt(h, m, s, ms).unwrap()
249    }
250
251    fn temp_library() -> (tempfile::TempDir, Library) {
252        let dir = tempfile::tempdir().unwrap();
253        let lib = Library::create(dir.path().join("Test.library")).unwrap();
254        (dir, lib)
255    }
256
257    #[test]
258    fn app_data_name_is_eagles_fnv_hash() {
259        // FNV-1a 32 reference values.
260        assert_eq!(fnv1a_hex(""), "811c9dc5");
261        assert_eq!(fnv1a_hex("a"), "e40c292c");
262        assert_eq!(fnv1a_hex("foobar"), "bf9cf968");
263        // Code units, not bytes: one unit per BMP character, two for others.
264        assert_ne!(fnv1a_hex("é"), fnv1a_hex("\u{c3}\u{a9}"));
265        let p = Path::new("/Users/me/Pictures/Books.library");
266        assert_eq!(app_data_backup_name(p), format!("{}.json", fnv1a_hex("/Users/me/Pictures/Books.library")));
267        assert_eq!(app_data_backup_name(Path::new("/Users/me/Pictures/Books.library/")), app_data_backup_name(p));
268    }
269
270    #[test]
271    fn app_data_copy_follows_each_new_backup() {
272        let (d, mut lib) = temp_library();
273        let app = d.path().join("app/eagle-backup");
274        let copy = app.join(app_data_backup_name(lib.root()));
275        backup_library_with(lib.root(), Some(&app)).unwrap().unwrap();
276        assert_eq!(fs::read(&copy).unwrap(), fs::read(lib.root().join(METADATA)).unwrap());
277        lib.create_folder(None, "Changed").unwrap();
278        backup_library_with(lib.root(), Some(&app)).unwrap().unwrap();
279        assert_eq!(fs::read(&copy).unwrap(), fs::read(lib.root().join(METADATA)).unwrap());
280        assert_eq!(fs::read_dir(&app).unwrap().count(), 1, "one copy per library, no temp files left");
281    }
282
283    #[test]
284    fn never_recreates_a_missing_library() {
285        let (d, lib) = temp_library();
286        let root = lib.root().to_path_buf();
287        drop(lib);
288        fs::remove_dir_all(&root).unwrap();
289        assert_eq!(backup_library_with(&root, Some(&d.path().join("app"))).unwrap(), None);
290        assert!(!root.exists());
291    }
292
293    #[test]
294    fn name_matches_eagle() {
295        assert_eq!(backup_name(at(2, 15, 25, 310)), "backup-2026-10-26 02.15.25.310.json");
296        assert_eq!(backup_name(at(14, 5, 7, 9)), "backup-2026-10-26 14.05.07.009.json");
297    }
298
299    #[test]
300    fn skips_when_newest_has_same_size() {
301        let (_d, mut lib) = temp_library();
302        let first = backup_at(lib.root(), at(1, 0, 0, 0)).unwrap().unwrap();
303        assert_eq!(fs::read(&first).unwrap(), fs::read(lib.root().join(METADATA)).unwrap());
304        assert_eq!(backup_at(lib.root(), at(1, 0, 1, 0)).unwrap(), None);
305        lib.create_folder(None, "Longer metadata now").unwrap();
306        let second = backup_at(lib.root(), at(1, 0, 2, 0)).unwrap().unwrap();
307        assert_eq!(lib.backups().unwrap(), vec![second, first]);
308    }
309
310    #[test]
311    fn refuses_damaged_metadata() {
312        let (_d, lib) = temp_library();
313        fs::write(lib.root().join(METADATA), b"{\"folders\":[]}").unwrap();
314        assert_eq!(backup_at(lib.root(), at(1, 0, 0, 0)).unwrap(), None);
315        fs::write(lib.root().join(METADATA), b"{not json").unwrap();
316        assert_eq!(backup_at(lib.root(), at(1, 0, 0, 0)).unwrap(), None);
317        assert!(lib.backups().unwrap().is_empty());
318    }
319
320    #[test]
321    fn keeps_at_most_max_count() {
322        let (_d, lib) = temp_library();
323        let dir = lib.root().join(BACKUP_DIR);
324        fs::create_dir_all(&dir).unwrap();
325        for i in 0..120 {
326            fs::write(dir.join(backup_name(at(0, i / 60, i % 60, 0))), b"x").unwrap();
327        }
328        fs::write(dir.join("unrelated.txt"), b"keep").unwrap();
329        let new = backup_at(lib.root(), at(5, 0, 0, 0)).unwrap().unwrap();
330        let left = lib.backups().unwrap();
331        assert_eq!(left.len(), MAX_BACKUP_COUNT);
332        assert_eq!(left[0], new);
333        // The newest 99 old ones survive.
334        assert_eq!(left[1], dir.join(backup_name(at(0, 1, 59, 0))));
335        assert_eq!(left[99], dir.join(backup_name(at(0, 0, 21, 0))));
336        assert!(dir.join("unrelated.txt").exists());
337    }
338
339    #[test]
340    fn restores_from_chosen_backup() {
341        let (_d, mut lib) = temp_library();
342        let before = backup_at(lib.root(), at(1, 0, 0, 0)).unwrap().unwrap();
343        lib.create_folder(None, "Later").unwrap();
344        assert_eq!(lib.metadata.folders.len(), 1);
345        lib.restore_metadata_from_backup(&before).unwrap();
346        assert!(lib.metadata.folders.is_empty());
347        assert_eq!(fs::read(lib.root().join(METADATA)).unwrap(), fs::read(&before).unwrap());
348        let damaged = fs::read_dir(lib.root().join(BACKUP_DIR))
349            .unwrap()
350            .filter_map(|e| e.ok())
351            .filter(|e| e.file_name().to_string_lossy().starts_with("metadata.json.damaged-"))
352            .count();
353        assert_eq!(damaged, 1);
354        let bad = lib.root().join(BACKUP_DIR).join("backup-bad.json");
355        fs::write(&bad, b"[]").unwrap();
356        assert!(lib.restore_metadata_from_backup(&bad).is_err());
357    }
358
359    #[test]
360    fn open_or_restore_recovers_corrupt_metadata() {
361        let (_d, mut lib) = temp_library();
362        lib.create_folder(None, "Kept").unwrap();
363        let good = backup_at(lib.root(), at(1, 0, 0, 0)).unwrap().unwrap();
364        // A newer but unusable backup is passed over.
365        let dir = lib.root().join(BACKUP_DIR);
366        fs::write(dir.join(backup_name(at(2, 0, 0, 0))), b"{\"folders\":").unwrap();
367        fs::write(dir.join(backup_name(at(3, 0, 0, 0))), b"").unwrap();
368        fs::write(lib.root().join(METADATA), b"{\"folders\":[").unwrap();
369        assert!(Library::open(lib.root()).is_err());
370
371        let (reopened, used) = Library::open_or_restore(lib.root()).unwrap();
372        assert_eq!(used, Some(good));
373        assert_eq!(reopened.metadata.folders[0].name, "Kept");
374
375        // A healthy library opens with no restore.
376        let (_, used) = Library::open_or_restore(lib.root()).unwrap();
377        assert_eq!(used, None);
378    }
379}