Skip to main content

falcon_library/
import_check.rs

1//! The duplicate check on import, after Eagle's Duplicate File Alert.
2//!
3//! Eagle finds an existing item with the same metadata key (see the Import
4//! chapter) and then compares the first 4,096,000 bytes of both files.
5//! falcon looks for items, not in the Trash, with the same size and
6//! extension, and compares the whole files by BLAKE3 hash, which is at
7//! least as strict. Eagle skips svg, tif, tiff and the formats its key
8//! leaves empty (bmp, tga, txt, and anything it can't preview); falcon
9//! skips the same list and files without an extension.
10//!
11//! What happens to a duplicate is the caller's choice (the app asks):
12//! import it anyway, drop it, or use the existing item instead, which
13//! [`Library::use_existing_for_import`] does.
14
15use std::path::{Path, PathBuf};
16
17use crate::duplicates::content_hash;
18use crate::error::{Error, Result};
19use crate::library::Library;
20
21/// Extensions the check leaves alone.
22pub const UNCHECKED: &[&str] = &["svg", "tif", "tiff", "bmp", "tga", "txt"];
23
24/// Whether files with extension `ext` (lowercase) are checked.
25pub fn is_checked(ext: &str) -> bool {
26    !ext.is_empty() && !UNCHECKED.contains(&ext)
27}
28
29/// The id of the first candidate whose file has the same bytes as `path`.
30pub fn same_file(path: &Path, candidates: &[(String, PathBuf)]) -> Option<String> {
31    if candidates.is_empty() {
32        return None;
33    }
34    let hash = content_hash(path).ok()?;
35    candidates.iter().find(|(_, p)| content_hash(p).is_ok_and(|h| h == hash)).map(|(id, _)| id.clone())
36}
37
38impl Library {
39    /// Items, not in the Trash, that a new file of `size` bytes with
40    /// extension `ext` could duplicate, with their originals' paths. Empty
41    /// for extensions that aren't checked. Cheap: no file is read.
42    pub fn duplicate_candidates(&self, size: u64, ext: &str) -> Vec<(String, PathBuf)> {
43        if !is_checked(ext) {
44            return Vec::new();
45        }
46        self.items()
47            .iter()
48            .filter(|i| !i.is_deleted && i.size == size && i.ext.eq_ignore_ascii_case(ext))
49            .map(|i| (i.id.clone(), i.original_path(self.root())))
50            .collect()
51    }
52
53    /// Eagle's "Use existing file": instead of importing a duplicate, the
54    /// existing item joins each of `folders` it isn't in yet, taking that
55    /// folder's auto-tags (see [`crate::autotag`]), and its
56    /// `modificationTime` becomes the import time, so it sorts with the
57    /// new items. Other tags and notes of the would-be import are not
58    /// carried over, as in Eagle.
59    pub fn use_existing_for_import(&mut self, existing: &str, folders: &[String]) -> Result<()> {
60        let Some(mut item) = self.item(existing).cloned() else {
61            return Err(Error::Import { path: self.root().to_path_buf(), reason: format!("no item {existing}") });
62        };
63        let known = self.folder_ids();
64        for f in folders.iter().filter(|f| known.contains(f.as_str())) {
65            crate::autotag::join_folder(&mut item, &self.metadata.folders, f);
66        }
67        item.modification_time = self.next_import_time(crate::edit::now_ms());
68        self.update_item(item)
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::import::{ImportOptions, stage};
76
77    #[test]
78    fn unchecked_formats() {
79        assert!(is_checked("png") && is_checked("mp4") && is_checked("ttf"));
80        for ext in ["svg", "tif", "tiff", "bmp", "tga", "txt", ""] {
81            assert!(!is_checked(ext), "{ext}");
82        }
83    }
84
85    #[test]
86    fn finds_and_uses_the_existing_copy() {
87        let tmp = tempfile::tempdir().unwrap();
88        let mut lib = Library::create(tmp.path().join("D.library")).unwrap();
89        let folder = lib.create_folder(None, "F").unwrap();
90        lib.set_folder_auto_tags(&folder, &["auto".into()]).unwrap();
91        let png = |name: &str, shade: u8| {
92            let p = tmp.path().join(name);
93            image::RgbImage::from_pixel(8, 8, image::Rgb([shade, 0, 0])).save(&p).unwrap();
94            p
95        };
96        let opts = ImportOptions { palette: false, tags: vec!["mine".into()], ..Default::default() };
97        let a = lib.import_file(png("a.png", 10), &opts).unwrap();
98        let old_time = lib.item(&a).unwrap().modification_time;
99
100        // Same bytes, other name: a duplicate of `a`.
101        let into = ImportOptions { folders: vec![folder.clone()], tags: vec!["new".into()], ..opts.clone() };
102        let copy = stage(png("copy.png", 10), &into).unwrap();
103        let cands = lib.duplicate_candidates(copy.item().size, &copy.item().ext);
104        assert_eq!(cands.len(), 1);
105        assert_eq!(same_file(&copy.original(), &cands), Some(a.clone()));
106
107        // Other bytes, even against the same candidates: not a duplicate.
108        let other = stage(png("other.png", 200), &into).unwrap();
109        assert_eq!(same_file(&other.original(), &cands), None);
110        // Another extension or the Trash: no candidates.
111        assert!(lib.duplicate_candidates(copy.item().size, "jpg").is_empty());
112        assert!(lib.duplicate_candidates(copy.item().size, "svg").is_empty());
113
114        let before = lib.items().len();
115        lib.use_existing_for_import(&a, &into.folders).unwrap();
116        let item = lib.item(&a).unwrap();
117        assert_eq!(item.folders, std::slice::from_ref(&folder));
118        assert_eq!(item.tags, ["mine", "auto"], "the folder's auto-tags, not the import's tags");
119        assert!(item.modification_time > old_time);
120        assert_eq!(lib.items().len(), before);
121        drop(copy);
122
123        lib.set_trashed(std::slice::from_ref(&a), true).unwrap();
124        assert!(lib.duplicate_candidates(lib.item(&a).unwrap().size, "png").is_empty());
125        assert!(lib.use_existing_for_import("nope", &[]).is_err());
126    }
127}