Skip to main content

falcon_library/
sync.rs

1//! Picking up changes other apps make to an open library: a sync tool
2//! (Dropbox, iCloud, Syncthing), Eagle on another machine, or the falcon
3//! CLI.
4//!
5//! Eagle polls `metadata.json` and `mtime.json` every 4 seconds
6//! (`fs.watchFile`). When `metadata.json` changed and its
7//! `modificationTime` is later than the one in memory, it takes the new
8//! folder tree. When `mtime.json` changed, it compares it with the stamps
9//! it knows: ids it hasn't seen are new items and larger stamps are edited
10//! ones, whose `metadata.json` it reads again; when the `all` count no
11//! longer matches the items it holds, it lists `images/` to find items
12//! that were deleted (and new folders missing from the index).
13//!
14//! Here the polling and file reading happen on the app's thread of choice
15//! ([`Probe`], [`read_metadata`], [`read_mtime`]); applying is quick and
16//! done with the library locked ([`Library::take_external_metadata`],
17//! [`Library::sync_items`]). Applying never writes: the changes are already
18//! on disk. falcon's own writes come back through the same path and change
19//! nothing, since their stamps are the ones in memory.
20
21use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::path::Path;
24use std::time::SystemTime;
25
26use crate::error::{Error, Result};
27use crate::library::Library;
28use crate::model::{Item, LibraryMetadata, MtimeIndex};
29
30/// How often Eagle polls the two files.
31pub const POLL: std::time::Duration = std::time::Duration::from_secs(4);
32
33/// What a poll of the library folder found.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub struct Probe {
36    /// `metadata.json` exists (if not, the library is gone or broken).
37    pub present: bool,
38    /// Modification times of `metadata.json` and `mtime.json`.
39    pub metadata: Option<SystemTime>,
40    /// `mtime.json`'s time, `None` while it is missing or empty (Eagle
41    /// ignores an empty `mtime.json`: it is being rewritten).
42    pub mtime: Option<SystemTime>,
43}
44
45impl Probe {
46    /// Looks at the two files under `root`.
47    pub fn of(root: &Path) -> Probe {
48        let meta = fs::metadata(root.join("metadata.json")).ok().filter(|m| m.is_file());
49        let mtime = fs::metadata(root.join("mtime.json")).ok().filter(|m| m.is_file() && m.len() > 0);
50        Probe { present: meta.is_some(), metadata: meta.and_then(|m| m.modified().ok()), mtime: mtime.and_then(|m| m.modified().ok()) }
51    }
52}
53
54/// Reads and parses `metadata.json` (an unfinished or damaged file is an
55/// error; poll again later).
56pub fn read_metadata(root: &Path) -> Result<LibraryMetadata> {
57    read(&root.join("metadata.json"))
58}
59
60/// Reads and parses `mtime.json`.
61pub fn read_mtime(root: &Path) -> Result<MtimeIndex> {
62    read(&root.join("mtime.json"))
63}
64
65fn read<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
66    let bytes = fs::read(path).map_err(|e| Error::io(path, e))?;
67    serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))
68}
69
70/// What [`Library::sync_items`] changed in memory.
71#[derive(Debug, Clone, Default, PartialEq)]
72pub struct ItemSync {
73    pub added: Vec<String>,
74    pub changed: Vec<String>,
75    pub removed: Vec<String>,
76}
77
78impl ItemSync {
79    pub fn is_empty(&self) -> bool {
80        self.added.is_empty() && self.changed.is_empty() && self.removed.is_empty()
81    }
82}
83
84impl Library {
85    /// Takes `metadata` read from disk if it is newer than what falcon
86    /// last read or wrote (its `modificationTime` is later), as Eagle
87    /// does; returns whether it did. A newer folder tree replaces the one
88    /// in memory, including edits still waiting in batched mode, and
89    /// becomes the base later writes are checked against.
90    pub fn take_external_metadata(&mut self, metadata: LibraryMetadata) -> bool {
91        if metadata.modification_time <= self.disk_stamp {
92            return false;
93        }
94        self.disk_stamp = metadata.modification_time;
95        self.metadata = metadata;
96        self.committed.metadata = self.metadata.clone();
97        self.dirty.metadata = false;
98        true
99    }
100
101    /// Brings the items in memory up to date with `index`, the
102    /// `mtime.json` just read from disk: items it lists that falcon
103    /// doesn't hold are read and added, items with a later stamp are read
104    /// again, and when its `all` count differs from the number of items
105    /// held, `images/` is listed to drop items whose folder is gone and add
106    /// folders the index missed. Items that can't be read yet (a sync still
107    /// copying them) are left for the next poll. Nothing is written.
108    pub fn sync_items(&mut self, index: &MtimeIndex) -> ItemSync {
109        let mut out = ItemSync::default();
110        let known: HashMap<String, u64> = self.mtime.entries.iter().cloned().collect();
111        for (id, stamp) in &index.entries {
112            match known.get(id) {
113                Some(&mine) if *stamp <= mine => {}
114                had => {
115                    if let Some(item) = self.read_item(id) {
116                        let new = had.is_none() && self.item(id).is_none();
117                        self.adopt(item, *stamp);
118                        if new { out.added.push(id.clone()) } else { out.changed.push(id.clone()) }
119                    }
120                }
121            }
122        }
123        let held = self.items().len() as u64;
124        if index.all.is_some_and(|all| all != held) || index.entries.len() as u64 != held {
125            let on_disk = self.item_dirs();
126            let gone: Vec<String> = self.items().iter().filter(|i| !on_disk.contains(&i.id)).map(|i| i.id.clone()).collect();
127            for id in gone {
128                self.drop_item(&id);
129                out.removed.push(id);
130            }
131            let mut fresh: Vec<&String> = on_disk.iter().filter(|id| self.item(id).is_none()).collect();
132            fresh.sort();
133            for id in fresh.into_iter().cloned().collect::<Vec<_>>() {
134                if let Some(item) = self.read_item(&id) {
135                    let stamp = item.last_modified;
136                    self.adopt(item, stamp);
137                    out.added.push(id);
138                }
139            }
140        }
141        if !out.is_empty() {
142            self.mtime.all = Some(self.items().len() as u64);
143        }
144        out
145    }
146
147    /// Rebuilds `mtime.json` from the items in memory, as Eagle's Empty
148    /// Cache and Reload does after reading the library again: one entry
149    /// per item with a `lastModified`, in id order, then `all` (the item
150    /// count, as falcon always writes it).
151    pub fn rebuild_mtime(&mut self) -> Result<()> {
152        self.mtime.entries = self.items().iter().filter(|i| i.last_modified > 0).map(|i| (i.id.clone(), i.last_modified)).collect();
153        self.mtime.all = Some(self.items().len() as u64);
154        self.write_mtime()
155    }
156
157    /// Drops every write batched mode is holding back, for a library whose
158    /// folder is gone: writing them would fail, or put files where the
159    /// library used to be.
160    pub fn discard_pending_writes(&mut self) {
161        self.dirty = Default::default();
162    }
163
164    /// The item at `images/<id>.info/metadata.json`, if it reads and is
165    /// that id (Eagle's ids are 13 or 36 characters).
166    fn read_item(&self, id: &str) -> Option<Item> {
167        if id.len() != 13 && id.len() != 36 {
168            return None;
169        }
170        let file = self.root().join("images").join(format!("{id}.info")).join("metadata.json");
171        let text = fs::read_to_string(file).ok()?;
172        Item::from_json_str(&text).ok().filter(|i| i.id == id)
173    }
174
175    /// Ids of the item folders in `images/`.
176    fn item_dirs(&self) -> HashSet<String> {
177        let Ok(dir) = fs::read_dir(self.root().join("images")) else { return HashSet::new() };
178        dir.filter_map(|e| e.ok())
179            .filter(|e| e.path().is_dir())
180            .filter_map(|e| e.file_name().to_string_lossy().strip_suffix(".info").map(String::from))
181            .collect()
182    }
183
184    /// Puts an item read from disk in memory with its `mtime.json` stamp.
185    /// A pending write of the old copy is dropped: the disk copy is newer.
186    pub(crate) fn adopt(&mut self, item: Item, stamp: u64) {
187        self.dirty.items.remove(&item.id);
188        self.skipped.retain(|(dir, _)| !dir.ends_with(format!("{}.info", item.id)));
189        match self.mtime.entries.iter_mut().find(|(id, _)| *id == item.id) {
190            Some(e) => e.1 = stamp,
191            None => self.mtime.entries.push((item.id.clone(), stamp)),
192        }
193        let items = self.items_mut();
194        match items.binary_search_by(|i| i.id.cmp(&item.id)) {
195            Ok(pos) => items[pos] = item,
196            Err(pos) => items.insert(pos, item),
197        }
198    }
199
200    /// Forgets an item deleted elsewhere (its folder is already gone).
201    fn drop_item(&mut self, id: &str) {
202        self.dirty.items.remove(id);
203        self.items_mut().retain(|i| i.id != id);
204        self.mtime.entries.retain(|(e, _)| e != id);
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::import::ImportOptions;
212
213    fn library() -> (tempfile::TempDir, Library) {
214        let dir = tempfile::tempdir().unwrap();
215        let lib = Library::create(dir.path().join("S.library")).unwrap();
216        (dir, lib)
217    }
218
219    fn import(lib: &mut Library, name: &str) -> String {
220        let src = lib.root().parent().unwrap().join(name);
221        fs::write(&src, name.as_bytes()).unwrap();
222        lib.import_file(&src, &ImportOptions::default()).unwrap()
223    }
224
225    #[test]
226    fn rebuilds_a_broken_index() {
227        let (_d, mut lib) = library();
228        let a = import(&mut lib, "a.txt");
229        let b = import(&mut lib, "b.txt");
230        fs::write(lib.root().join("mtime.json"), b"{\"bogus\":1}").unwrap();
231        let mut fresh = Library::open(lib.root()).unwrap();
232        fresh.rebuild_mtime().unwrap();
233        let disk = read_mtime(lib.root()).unwrap();
234        let mut want = vec![(a.clone(), lib.item(&a).unwrap().last_modified), (b.clone(), lib.item(&b).unwrap().last_modified)];
235        want.sort();
236        assert_eq!(disk.entries, want);
237        assert_eq!(disk.all, Some(2));
238    }
239
240    #[test]
241    fn probe_sees_both_files() {
242        let (_d, lib) = library();
243        let p = Probe::of(lib.root());
244        assert!(p.present && p.metadata.is_some() && p.mtime.is_some());
245        fs::write(lib.root().join("mtime.json"), b"").unwrap();
246        assert_eq!(Probe::of(lib.root()).mtime, None, "an empty mtime.json is being rewritten");
247        assert!(!Probe::of(&lib.root().join("nope")).present);
248    }
249
250    #[test]
251    fn newer_metadata_from_disk_replaces_the_folder_tree() {
252        let (_d, mut lib) = library();
253        lib.create_folder(None, "Here").unwrap();
254        // Our own write read back changes nothing.
255        let mine = read_metadata(lib.root()).unwrap();
256        assert!(!lib.take_external_metadata(mine.clone()));
257        // Another device adds a folder, later.
258        let mut other = Library::open(lib.root()).unwrap();
259        std::thread::sleep(std::time::Duration::from_millis(2));
260        other.create_folder(None, "There").unwrap();
261        let theirs = read_metadata(lib.root()).unwrap();
262        assert!(lib.take_external_metadata(theirs));
263        assert_eq!(lib.metadata.folders.len(), 2);
264        // Now our next write is not a conflict.
265        lib.create_folder(None, "Again").unwrap();
266        // An older copy (a stale read) is ignored.
267        assert!(!lib.take_external_metadata(mine));
268        assert_eq!(lib.metadata.folders.len(), 3);
269    }
270
271    #[test]
272    fn items_added_edited_and_deleted_elsewhere_are_picked_up() {
273        let (_d, mut lib) = library();
274        let kept = import(&mut lib, "a.txt");
275        let doomed = import(&mut lib, "b.txt");
276        // Our own index read back changes nothing.
277        assert!(lib.sync_items(&read_mtime(lib.root()).unwrap()).is_empty());
278
279        let mut other = Library::open(lib.root()).unwrap();
280        let added = import(&mut other, "c.txt");
281        let mut item = other.item(&kept).unwrap().clone();
282        item.annotation = "edited elsewhere".into();
283        std::thread::sleep(std::time::Duration::from_millis(2));
284        other.update_item(item).unwrap();
285        other.delete_permanently(std::slice::from_ref(&doomed)).unwrap();
286
287        let got = lib.sync_items(&read_mtime(lib.root()).unwrap());
288        assert_eq!(got.added, vec![added.clone()]);
289        assert_eq!(got.changed, vec![kept.clone()]);
290        assert_eq!(got.removed, vec![doomed.clone()]);
291        assert_eq!(lib.item(&kept).unwrap().annotation, "edited elsewhere");
292        assert!(lib.item(&doomed).is_none());
293        assert_eq!(lib.mtime, other.mtime, "the index in memory matches the disk");
294        assert!(lib.sync_items(&read_mtime(lib.root()).unwrap()).is_empty());
295    }
296
297    #[test]
298    fn a_folder_the_index_missed_is_found_by_the_count() {
299        let (_d, mut lib) = library();
300        import(&mut lib, "a.txt");
301        let mut other = Library::open(lib.root()).unwrap();
302        let id = import(&mut other, "b.txt");
303        // The sync tool delivered the item folder but an old mtime.json.
304        let mut stale = read_mtime(lib.root()).unwrap();
305        stale.entries.retain(|(e, _)| *e != id);
306        let got = lib.sync_items(&stale);
307        assert_eq!(got.added, vec![id]);
308        // A half-copied item (unreadable) waits for a later poll.
309        let mut third = Library::open(lib.root()).unwrap();
310        let late = import(&mut third, "c.txt");
311        fs::write(lib.root().join(format!("images/{late}.info/metadata.json")), b"{\"id\":").unwrap();
312        assert!(lib.sync_items(&read_mtime(lib.root()).unwrap()).added.is_empty());
313    }
314}