Skip to main content

falcon_library/
library.rs

1//! Loading and saving whole libraries.
2
3use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9
10use crate::disk_name::on_disk;
11use crate::error::{Error, Result};
12use crate::model::{Folder, Item, LibraryMetadata, MtimeIndex, SavedFilter, Tags};
13use crate::undo::{Committed, Dirty, Transaction, WriteMode};
14
15const METADATA: &str = "metadata.json";
16const MTIME: &str = "mtime.json";
17const TAGS: &str = "tags.json";
18const FILTERS: &str = "saved-filters.json";
19const IMAGES: &str = "images";
20
21/// The Eagle release whose library format falcon reads and writes. A
22/// library whose `applicationVersion` is newer is refused, as that Eagle
23/// refuses it.
24pub const APP_VERSION: &str = "4.0.0";
25
26/// An Eagle library loaded into memory (item payload files stay on disk).
27#[derive(Debug, Clone)]
28pub struct Library {
29    root: PathBuf,
30    /// `metadata.json`.
31    pub metadata: LibraryMetadata,
32    /// `mtime.json`.
33    pub mtime: MtimeIndex,
34    /// `tags.json`.
35    pub tags: Tags,
36    /// `saved-filters.json`, in menu order.
37    pub saved_filters: Vec<SavedFilter>,
38    items: Vec<Item>,
39    /// Item folders whose `metadata.json` couldn't be read or parsed (for
40    /// example zeroed by a sync tool), with the reason. They are left out
41    /// and never written to.
42    pub skipped: Vec<(PathBuf, String)>,
43    /// Last `modificationTime` handed out by an import, to keep them strictly increasing.
44    last_import_ms: u64,
45    /// The open undo transaction, if any (see [`Library::begin_transaction`]).
46    pub(crate) journal: Option<Transaction>,
47    /// `metadata`, `tags` and `saved_filters` as last saved, so a transaction
48    /// can record what an edit replaced.
49    pub(crate) committed: Committed,
50    pub(crate) write_mode: WriteMode,
51    /// Writes held back in [`WriteMode::Batched`] until [`Library::flush`].
52    pub(crate) dirty: Dirty,
53    /// `modificationTime` of `metadata.json` as falcon last read or wrote
54    /// it. A file on disk with a later one was changed by someone else.
55    pub(crate) disk_stamp: u64,
56    /// Set when a `metadata.json` write was refused (see
57    /// [`Library::take_conflict`]).
58    conflict: bool,
59}
60
61impl Library {
62    /// Loads the library at `path`. Items are sorted by id.
63    pub fn open(path: impl AsRef<Path>) -> Result<Library> {
64        Library::open_with_progress(path, &|_, _| {})
65    }
66
67    /// [`Library::open`], calling `progress(read, total)` as item folders
68    /// are read (first with 0 once the folder list is known), for a
69    /// loading screen.
70    pub fn open_with_progress(path: impl AsRef<Path>, progress: &dyn Fn(usize, usize)) -> Result<Library> {
71        let root = path.as_ref().to_path_buf();
72        if !root.join(METADATA).is_file() || !root.join(IMAGES).is_dir() {
73            return Err(Error::NotALibrary(root));
74        }
75        let metadata: LibraryMetadata = read_json(&root.join(METADATA))?;
76        if is_newer_than_app(&metadata.application_version) {
77            return Err(Error::NewerLibrary { path: root, version: metadata.application_version });
78        }
79        let mtime = read_json(&root.join(MTIME))?;
80        let tags: Tags = read_json(&root.join(TAGS))?;
81        let saved_filters: Vec<SavedFilter> = read_json(&root.join(FILTERS))?;
82
83        let images = root.join(IMAGES);
84        let mut dirs = Vec::new();
85        for entry in fs::read_dir(&images).map_err(|e| Error::io(&images, e))? {
86            let entry = entry.map_err(|e| Error::io(&images, e))?;
87            if entry.file_name().to_string_lossy().ends_with(".info") && entry.path().is_dir() {
88                dirs.push(entry.path());
89            }
90        }
91        let total = dirs.len();
92        progress(0, total);
93        let mut items = Vec::new();
94        let mut skipped = Vec::new();
95        for (n, dir) in dirs.into_iter().enumerate() {
96            // One damaged item must not make the whole library unreadable.
97            let file = dir.join(METADATA);
98            match fs::read_to_string(&file).map_err(|e| e.to_string()).and_then(|t| Item::from_json_str(&t).map_err(|e| e.to_string())) {
99                Ok(item) => items.push(item),
100                Err(e) => skipped.push((dir, e)),
101            }
102            if (n + 1) % 200 == 0 || n + 1 == total {
103                progress(n + 1, total);
104            }
105        }
106        skipped.sort();
107        items.sort_by(|a, b| a.id.cmp(&b.id));
108        let committed = Committed { metadata: metadata.clone(), tags: tags.clone(), saved_filters: saved_filters.clone() };
109        let disk_stamp = metadata.modification_time;
110        Ok(Library {
111            root,
112            metadata,
113            mtime,
114            tags,
115            saved_filters,
116            items,
117            skipped,
118            last_import_ms: 0,
119            journal: None,
120            committed,
121            write_mode: WriteMode::Immediate,
122            dirty: Dirty::default(),
123            disk_stamp,
124            conflict: false,
125        })
126    }
127
128    /// Creates an empty library at `path` (normally ending in `.library`)
129    /// with the files Eagle expects, and opens it. Refuses a folder that
130    /// already exists.
131    pub fn create(path: impl AsRef<Path>) -> Result<Library> {
132        let root = path.as_ref().to_path_buf();
133        if root.exists() {
134            return Err(Error::Io {
135                path: root.clone(),
136                source: std::io::Error::new(std::io::ErrorKind::AlreadyExists, "already exists"),
137            });
138        }
139        mkdir(&root.join(IMAGES))?;
140        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64);
141        let metadata = LibraryMetadata {
142            folders: Vec::new(),
143            smart_folders: Vec::new(),
144            quick_access: Vec::new(),
145            tags_groups: Vec::new(),
146            modification_time: now,
147            application_version: APP_VERSION.into(),
148            extra: Default::default(),
149        };
150        write_json(&root.join(METADATA), &metadata)?;
151        write_json(&root.join(MTIME), &MtimeIndex { entries: Vec::new(), all: Some(0) })?;
152        write_json(&root.join(TAGS), &Tags::default())?;
153        write_json(&root.join(FILTERS), &Vec::<SavedFilter>::new())?;
154        Library::open(root)
155    }
156
157    /// The library's root folder.
158    pub fn root(&self) -> &Path {
159        &self.root
160    }
161
162    /// All items (including trashed ones), sorted by id.
163    pub fn items(&self) -> &[Item] {
164        &self.items
165    }
166
167    /// Looks up an item by id.
168    pub fn item(&self, id: &str) -> Option<&Item> {
169        self.items.iter().find(|i| i.id == id)
170    }
171
172    /// Mutable access to the items, for edits before [`Library::save_to`].
173    pub fn items_mut(&mut self) -> &mut Vec<Item> {
174        &mut self.items
175    }
176
177    /// Every folder in the tree, depth first.
178    pub fn all_folders(&self) -> Vec<&Folder> {
179        let mut out = Vec::new();
180        for f in &self.metadata.folders {
181            f.walk(&mut |x| out.push(x));
182        }
183        out
184    }
185
186    /// Ids of all folders in the tree.
187    pub fn folder_ids(&self) -> HashSet<&str> {
188        self.all_folders().into_iter().map(|f| f.id.as_str()).collect()
189    }
190
191    /// Info directory of `item` in this library.
192    pub fn info_dir(&self, item: &Item) -> PathBuf {
193        item.info_dir(&self.root)
194    }
195
196    /// Writes the library to `dest` (created if needed), copying each item's
197    /// files and any other root entries (`backup/`, `Icon`, ...) unchanged,
198    /// and regenerating the JSON files from the in-memory model.
199    pub fn save_to(&self, dest: impl AsRef<Path>) -> Result<()> {
200        let dest = dest.as_ref();
201        mkdir(dest)?;
202        let same = |a: &Path, b: &Path| matches!((a.canonicalize(), b.canonicalize()), (Ok(a), Ok(b)) if a == b);
203        if same(dest, &self.root) {
204            return Err(Error::SaveOntoSelf(dest.to_path_buf()));
205        }
206
207        // Unmodelled root entries are copied verbatim.
208        let modelled = [METADATA, MTIME, TAGS, FILTERS, IMAGES];
209        for entry in fs::read_dir(&self.root).map_err(|e| Error::io(&self.root, e))? {
210            let entry = entry.map_err(|e| Error::io(&self.root, e))?;
211            if !modelled.iter().any(|m| entry.file_name() == *m) {
212                copy_recursive(&entry.path(), &dest.join(entry.file_name()))?;
213            }
214        }
215
216        write_json(&dest.join(METADATA), &self.metadata)?;
217        write_json(&dest.join(MTIME), &self.mtime)?;
218        write_json(&dest.join(TAGS), &self.tags)?;
219        write_json(&dest.join(FILTERS), &self.saved_filters)?;
220
221        let images = dest.join(IMAGES);
222        mkdir(&images)?;
223        for item in &self.items {
224            let src = self.info_dir(item);
225            let out = item.info_dir(dest);
226            mkdir(&out)?;
227            if src.is_dir() {
228                for entry in fs::read_dir(&src).map_err(|e| Error::io(&src, e))? {
229                    let entry = entry.map_err(|e| Error::io(&src, e))?;
230                    if entry.file_name() != METADATA {
231                        copy_recursive(&entry.path(), &out.join(entry.file_name()))?;
232                    }
233                }
234            }
235            write_json(&out.join(METADATA), item)?;
236        }
237        // Items that couldn't be read are copied as they are.
238        for (dir, _) in &self.skipped {
239            if let Some(name) = dir.file_name() {
240                copy_recursive(dir, &images.join(name))?;
241            }
242        }
243        Ok(())
244    }
245}
246
247impl Library {
248    /// An import timestamp (ms) that is at least `now` and strictly greater
249    /// than the previous one handed out.
250    pub(crate) fn next_import_time(&mut self, now: u64) -> u64 {
251        self.last_import_ms = now.max(self.last_import_ms + 1);
252        self.last_import_ms
253    }
254
255    /// Drops an item from memory and `mtime.json` (its files are already
256    /// gone). This can't be undone, so an open transaction is marked
257    /// irreversible.
258    pub(crate) fn forget_item(&mut self, id: &str) -> Result<()> {
259        if let Some(j) = &mut self.journal {
260            j.irreversible = true;
261        }
262        self.dirty.items.remove(id);
263        self.items.retain(|i| i.id != id);
264        self.mtime.entries.retain(|(e, _)| e != id);
265        self.mtime.all = Some(self.items.len() as u64);
266        self.write_mtime()
267    }
268
269    /// Writes `item`'s `metadata.json` in place, adds it to the in-memory
270    /// list (kept sorted by id) and records it in `mtime.json`, whose `all`
271    /// becomes the item count. An open transaction records the item as it
272    /// was (or as new).
273    pub(crate) fn register_item(&mut self, item: Item) -> Result<()> {
274        self.journal_item(&item.id);
275        match self.write_mode {
276            WriteMode::Immediate => write_json(&item.info_dir(&self.root).join(METADATA), &item)?,
277            WriteMode::Batched => {
278                self.dirty.items.insert(item.id.clone());
279            }
280        }
281        let stamp = item.last_modified;
282        match self.mtime.entries.iter_mut().find(|(id, _)| *id == item.id) {
283            Some(entry) => entry.1 = stamp,
284            None => self.mtime.entries.push((item.id.clone(), stamp)),
285        }
286        match self.items.binary_search_by(|i| i.id.cmp(&item.id)) {
287            Ok(pos) => self.items[pos] = item,
288            Err(pos) => self.items.insert(pos, item),
289        }
290        self.mtime.all = Some(self.items.len() as u64);
291        self.write_mtime()
292    }
293
294    /// Writes the in-memory `metadata` to `metadata.json`, the one place
295    /// that file is written while the library is open. Like Eagle, it
296    /// first reads the file on disk and refuses (writing nothing) when it
297    /// was made by a newer Eagle or its `modificationTime` is later than
298    /// the one falcon last read or wrote, which means another app or a
299    /// sync tool changed it meanwhile ([`Error::SyncConflict`]). A file
300    /// that can't be read or parsed is overwritten.
301    pub(crate) fn write_metadata_file(&mut self) -> Result<()> {
302        let path = self.root.join(METADATA);
303        if let Some(disk) = fs::read(&path).ok().and_then(|b| serde_json::from_slice::<DiskStamp>(&b).ok()) {
304            if disk.application_version.as_deref().is_some_and(is_newer_than_app) {
305                self.conflict = true;
306                return Err(Error::NewerLibrary { path: self.root.clone(), version: disk.application_version.unwrap_or_default() });
307            }
308            if disk.modification_time.is_some_and(|t| t > self.disk_stamp) {
309                self.conflict = true;
310                return Err(Error::SyncConflict(self.root.clone()));
311            }
312        }
313        write_json(&path, &self.metadata)?;
314        self.disk_stamp = self.metadata.modification_time;
315        Ok(())
316    }
317
318    /// Whether a `metadata.json` write was refused since the last call
319    /// because the file on disk changed under falcon (or a newer Eagle
320    /// wrote it). The app then reloads the library from disk, as Eagle
321    /// does; any caller of the library (the HTTP API included) can trip it.
322    pub fn take_conflict(&mut self) -> bool {
323        std::mem::take(&mut self.conflict)
324    }
325
326    /// Writes `mtime.json`, or marks it dirty in batched mode.
327    pub(crate) fn write_mtime(&mut self) -> Result<()> {
328        match self.write_mode {
329            WriteMode::Immediate => write_json(&self.root.join(MTIME), &self.mtime),
330            WriteMode::Batched => {
331                self.dirty.mtime = true;
332                Ok(())
333            }
334        }
335    }
336}
337
338impl Item {
339    /// `<library>/images/<id>.info`.
340    pub fn info_dir(&self, library_root: &Path) -> PathBuf {
341        library_root.join(IMAGES).join(format!("{}.info", self.id))
342    }
343
344    /// Path of the original file, `<name>.<ext>` inside the info dir.
345    pub fn original_path(&self, library_root: &Path) -> PathBuf {
346        let file = if self.ext.is_empty() {
347            self.name.clone()
348        } else {
349            format!("{}.{}", self.name, self.ext)
350        };
351        self.info_dir(library_root).join(&*on_disk(&file))
352    }
353
354    /// The file name of the item's thumbnail, `<name>_thumbnail.png`.
355    pub fn thumbnail_name(&self) -> String {
356        on_disk(&format!("{}_thumbnail.png", self.name)).into_owned()
357    }
358
359    /// Path of `<name>_thumbnail.png`, if that file exists.
360    pub fn thumbnail_path(&self, library_root: &Path) -> Option<PathBuf> {
361        let p = self.info_dir(library_root).join(self.thumbnail_name());
362        p.is_file().then_some(p)
363    }
364}
365
366/// The two keys of `metadata.json` checked before it is overwritten.
367#[derive(serde::Deserialize)]
368#[serde(rename_all = "camelCase")]
369struct DiskStamp {
370    modification_time: Option<u64>,
371    application_version: Option<String>,
372}
373
374/// Whether a library written by Eagle `version` is too new for falcon.
375/// Unparseable versions are not refused.
376pub fn is_newer_than_app(version: &str) -> bool {
377    compare_versions(APP_VERSION, version) == Some(std::cmp::Ordering::Less)
378}
379
380/// Orders two version strings the way Eagle's version check does: an
381/// optional leading `v`, dot-separated numbers (missing ones count as 0),
382/// then an optional `-prerelease` that sorts before the release itself
383/// (its dot-separated parts compare as numbers when both are numeric, else
384/// as text, and fewer parts sort first). `+build` is ignored. `None` when
385/// either isn't a version.
386pub fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
387    use std::cmp::Ordering;
388    fn parse(v: &str) -> Option<(Vec<u64>, Option<Vec<&str>>)> {
389        let v = v.trim();
390        let v = v.strip_prefix('v').unwrap_or(v);
391        let v = v.split('+').next()?;
392        let (main, pre) = match v.split_once('-') {
393            Some((m, p)) => (m, Some(p.split('.').collect::<Vec<_>>())),
394            None => (v, None),
395        };
396        let nums = main.split('.').map(|p| p.parse::<u64>().ok()).collect::<Option<Vec<_>>>()?;
397        (!nums.is_empty() && nums.len() <= 4).then_some((nums, pre))
398    }
399    let (na, pa) = parse(a)?;
400    let (nb, pb) = parse(b)?;
401    for i in 0..na.len().max(nb.len()) {
402        match na.get(i).unwrap_or(&0).cmp(nb.get(i).unwrap_or(&0)) {
403            Ordering::Equal => {}
404            o => return Some(o),
405        }
406    }
407    Some(match (pa, pb) {
408        (None, None) => Ordering::Equal,
409        (None, Some(_)) => Ordering::Greater,
410        (Some(_), None) => Ordering::Less,
411        (Some(x), Some(y)) => {
412            for (p, q) in x.iter().zip(&y) {
413                let o = match (p.parse::<u64>(), q.parse::<u64>()) {
414                    (Ok(m), Ok(n)) => m.cmp(&n),
415                    _ => p.cmp(q),
416                };
417                if o != Ordering::Equal {
418                    return Some(o);
419                }
420            }
421            x.len().cmp(&y.len())
422        }
423    })
424}
425
426fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
427    let bytes = fs::read(path).map_err(|e| Error::io(path, e))?;
428    serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))
429}
430
431/// Eagle writes compact JSON with no trailing newline.
432pub(crate) fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
433    let bytes = serde_json::to_vec(value).map_err(|e| Error::json(path, e))?;
434    fs::write(path, bytes).map_err(|e| Error::io(path, e))
435}
436
437fn mkdir(p: &Path) -> Result<()> {
438    fs::create_dir_all(p).map_err(|e| Error::io(p, e))
439}
440
441fn copy_recursive(src: &Path, dst: &Path) -> Result<()> {
442    if src.is_dir() {
443        mkdir(dst)?;
444        for entry in fs::read_dir(src).map_err(|e| Error::io(src, e))? {
445            let entry = entry.map_err(|e| Error::io(src, e))?;
446            copy_recursive(&entry.path(), &dst.join(entry.file_name()))?;
447        }
448    } else {
449        fs::copy(src, dst).map_err(|e| Error::io(src, e))?;
450    }
451    Ok(())
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use std::cmp::Ordering::*;
458
459    #[test]
460    fn versions_compare_like_eagle() {
461        assert_eq!(compare_versions("4.0.0", "4.0.0"), Some(Equal));
462        assert_eq!(compare_versions("4.0.0", "4.0.1"), Some(Less));
463        assert_eq!(compare_versions("4.0.0", "4.1"), Some(Less));
464        assert_eq!(compare_versions("4.0.0", "4"), Some(Equal));
465        assert_eq!(compare_versions("4.0.0", "3.0.10"), Some(Greater));
466        assert_eq!(compare_versions("4.0.0", "10.0.0"), Some(Less), "numeric, not text");
467        assert_eq!(compare_versions("4.0.0", "4.0.0-beta.2"), Some(Greater));
468        assert_eq!(compare_versions("4.0.0-beta.10", "4.0.0-beta.2"), Some(Greater));
469        assert_eq!(compare_versions("4.0.0-alpha", "4.0.0-beta"), Some(Less));
470        assert_eq!(compare_versions("v4.0.0", "4.0.0+build7"), Some(Equal));
471        assert_eq!(compare_versions("4.0.0", "build 20"), None);
472        assert_eq!(compare_versions("4.0.0", ""), None);
473        assert!(is_newer_than_app("4.0.1"));
474        assert!(is_newer_than_app("5.0.0-beta"));
475        assert!(!is_newer_than_app("4.0.0"));
476        assert!(!is_newer_than_app("3.0.0"));
477        assert!(!is_newer_than_app("nonsense"));
478    }
479
480    fn set_disk_metadata(root: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
481        let path = root.join(METADATA);
482        let mut v: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
483        edit(&mut v);
484        fs::write(path, serde_json::to_vec(&v).unwrap()).unwrap();
485    }
486
487    #[test]
488    fn reports_loading_progress() {
489        let dir = tempfile::tempdir().unwrap();
490        let root = dir.path().join("P.library");
491        let mut lib = Library::create(&root).unwrap();
492        for n in 0..3 {
493            let src = dir.path().join(format!("{n}.txt"));
494            fs::write(&src, b"x").unwrap();
495            lib.import_file(&src, &crate::import::ImportOptions::default()).unwrap();
496        }
497        let seen = std::sync::Mutex::new(Vec::new());
498        let again = Library::open_with_progress(&root, &|a, b| seen.lock().unwrap().push((a, b))).unwrap();
499        assert_eq!(again.items().len(), 3);
500        assert_eq!(seen.into_inner().unwrap(), vec![(0, 3), (3, 3)]);
501    }
502
503    #[test]
504    fn refuses_a_library_from_a_newer_eagle() {
505        let dir = tempfile::tempdir().unwrap();
506        let root = dir.path().join("New.library");
507        Library::create(&root).unwrap();
508        set_disk_metadata(&root, |v| v["applicationVersion"] = "4.1.0".into());
509        match Library::open(&root) {
510            Err(Error::NewerLibrary { version, .. }) => assert_eq!(version, "4.1.0"),
511            other => panic!("expected NewerLibrary, got {other:?}"),
512        }
513        set_disk_metadata(&root, |v| v["applicationVersion"] = "3.0.5".into());
514        assert!(Library::open(&root).is_ok());
515    }
516
517    #[test]
518    fn metadata_writes_refuse_a_newer_copy_on_disk() {
519        let dir = tempfile::tempdir().unwrap();
520        let root = dir.path().join("Sync.library");
521        let mut lib = Library::create(&root).unwrap();
522        lib.create_folder(None, "Mine").unwrap();
523        // Our own writes never conflict with themselves.
524        lib.create_folder(None, "Mine too").unwrap();
525        // Another device saves later.
526        let later = lib.metadata.modification_time + 60_000;
527        set_disk_metadata(&root, |v| {
528            v["modificationTime"] = later.into();
529            v["folders"] = serde_json::json!([]);
530        });
531        let before = fs::read(root.join(METADATA)).unwrap();
532        assert!(matches!(lib.create_folder(None, "Lost"), Err(Error::SyncConflict(_))));
533        assert!(lib.take_conflict());
534        assert!(!lib.take_conflict(), "taken once");
535        assert_eq!(fs::read(root.join(METADATA)).unwrap(), before, "nothing written");
536
537        // Batched writes are refused at flush, and not retried.
538        let mut lib = Library::open(&root).unwrap();
539        lib.set_write_mode(WriteMode::Batched).unwrap();
540        lib.create_folder(None, "Pending").unwrap();
541        set_disk_metadata(&root, |v| v["modificationTime"] = (later + 60_000).into());
542        assert!(matches!(lib.flush(), Err(Error::SyncConflict(_))));
543        assert!(!lib.has_pending_writes());
544
545        // A newer Eagle's file is never overwritten either.
546        let mut lib = Library::open(&root).unwrap();
547        set_disk_metadata(&root, |v| v["applicationVersion"] = "9.0.0".into());
548        assert!(matches!(lib.create_folder(None, "No"), Err(Error::NewerLibrary { .. })));
549    }
550}