Skip to main content

falcon_library/
folder_import.rs

1//! Importing a folder from disk with its structure kept, after Eagle's
2//! `uploadFolderToSidebar`: the folder becomes an Eagle folder of the same
3//! name, each subfolder a child folder, and every file is imported into
4//! the folder that mirrors its parent directory.
5//!
6//! [`walk`] reads the tree, [`plan`] turns it into folders to create and
7//! files to import, and [`Library::create_folders`] adds the folders in one
8//! `metadata.json` write. Importing the files is left to the caller, which
9//! stages them on another thread.
10
11use std::cmp::Ordering;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::error::{Error, Result};
16use crate::library::Library;
17use crate::model::Folder;
18use crate::search::natural_cmp;
19
20/// A directory as walked from disk.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct Tree {
23    /// The directory itself.
24    pub dir: PathBuf,
25    /// Files directly inside it, in name order.
26    pub files: Vec<PathBuf>,
27    /// Its subdirectories, in name order.
28    pub folders: Vec<Tree>,
29}
30
31/// macOS packages Eagle treats as single files rather than folders to walk
32/// into (Keynote, Pixelmator and MindNode documents).
33const PACKAGES: [&str; 3] = ["key", "pxd", "mindnode"];
34
35/// Junk files that operating systems leave in folders; never imported.
36/// Names starting with a dot are skipped as hidden files as well.
37const JUNK: [&str; 4] = ["Thumbs.db", "ehthumbs.db", "Desktop.ini", "Icon\r"];
38
39/// Whether a walk leaves `name` out: hidden files and OS junk.
40pub fn is_skipped(name: &str) -> bool {
41    name.starts_with('.') || JUNK.iter().any(|j| j.eq_ignore_ascii_case(name))
42}
43
44fn is_package(path: &Path) -> bool {
45    path.extension().and_then(|e| e.to_str()).is_some_and(|e| PACKAGES.iter().any(|p| p.eq_ignore_ascii_case(e)))
46}
47
48fn by_name(a: &Path, b: &Path) -> Ordering {
49    let name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
50    natural_cmp(&name(a), &name(b)).then_with(|| a.cmp(b))
51}
52
53/// Reads `dir` and everything under it. Hidden files and junk are left
54/// out; packages count as files. Symbolic links to folders are not
55/// followed, so a link cycle can't make the walk endless. Unreadable
56/// subfolders come back empty.
57pub fn walk(dir: &Path) -> Tree {
58    let mut tree = Tree { dir: dir.to_path_buf(), ..Default::default() };
59    let Ok(entries) = fs::read_dir(dir) else { return tree };
60    let mut subdirs = Vec::new();
61    for entry in entries.flatten() {
62        let name = entry.file_name();
63        if is_skipped(&name.to_string_lossy()) {
64            continue;
65        }
66        let path = entry.path();
67        let Ok(ft) = entry.file_type() else { continue };
68        if ft.is_dir() && !is_package(&path) {
69            subdirs.push(path);
70        } else if ft.is_symlink() && path.is_dir() {
71            continue;
72        } else {
73            tree.files.push(path);
74        }
75    }
76    tree.files.sort_by(|a, b| by_name(a, b));
77    subdirs.sort_by(|a, b| by_name(a, b));
78    tree.folders = subdirs.iter().map(|d| walk(d)).collect();
79    tree
80}
81
82impl Tree {
83    /// How deep the tree goes and how many files it holds.
84    pub fn stats(&self) -> Stats {
85        let mut out = Stats { depth: 0, files: 0 };
86        let mut stack = vec![(self, 1)];
87        while let Some((node, depth)) = stack.pop() {
88            out.depth = out.depth.max(depth);
89            out.files += node.files.len();
90            stack.extend(node.folders.iter().map(|c| (c, depth + 1)));
91        }
92        out
93    }
94
95    /// Every file in the tree, folder by folder.
96    pub fn all_files(&self) -> Vec<PathBuf> {
97        let mut out = Vec::new();
98        let mut stack = vec![self];
99        while let Some(node) = stack.pop() {
100            out.extend(node.files.iter().cloned());
101            stack.extend(node.folders.iter().rev());
102        }
103        out
104    }
105}
106
107/// The size of a walked tree.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Stats {
110    /// Levels of folders, counting the walked folder itself as 1.
111    pub depth: usize,
112    /// Files at every level.
113    pub files: usize,
114}
115
116/// From this many files Eagle asks before importing a folder.
117pub const WARN_FILES: usize = 2_000;
118/// Eagle refuses a folder with this many files or more...
119pub const MAX_FILES: usize = 100_000;
120/// ...or this many levels of folders or more.
121pub const MAX_DEPTH: usize = 15;
122
123/// Whether a folder may be imported, after Eagle's limits in
124/// `uploadFolderToSidebar`.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum Verdict {
127    /// Import it.
128    Import,
129    /// Ask first: it holds this many files.
130    Confirm(usize),
131    /// Too deep or too many files to import.
132    Refuse,
133}
134
135/// Eagle's limits: 15 levels or more, or 100,000 files or more, are
136/// refused; 2,000 files or more need a confirmation.
137pub fn verdict(stats: Stats) -> Verdict {
138    if stats.depth >= MAX_DEPTH || stats.files >= MAX_FILES {
139        Verdict::Refuse
140    } else if stats.files >= WARN_FILES {
141        Verdict::Confirm(stats.files)
142    } else {
143        Verdict::Import
144    }
145}
146
147/// Pixave libraries, which folder import skips as Eagle's Import Folders
148/// does.
149pub fn is_pixave_library(path: &Path) -> bool {
150    path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pxvlibrary"))
151}
152
153/// A folder to create for an imported tree.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct NewFolder {
156    /// The new folder's id.
157    pub id: String,
158    /// Its name: the directory's own name.
159    pub name: String,
160    /// The folder it goes in; `None` for the top level.
161    pub parent: Option<String>,
162}
163
164/// What importing a [`Tree`] does to a library.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct Plan {
167    /// Folders to create, each after its parent, siblings in name order.
168    pub folders: Vec<NewFolder>,
169    /// Every file with the id of the folder it is imported into.
170    pub files: Vec<(PathBuf, String)>,
171}
172
173impl Plan {
174    /// The id of the folder made for the walked directory itself.
175    pub fn root(&self) -> Option<&str> {
176        self.folders.first().map(|f| f.id.as_str())
177    }
178}
179
180/// Maps a walked tree to the folders to create and the file imports, with
181/// the top folder placed in `parent` (or at the top level). `new_id` gives
182/// each folder its id.
183pub fn plan(tree: &Tree, parent: Option<&str>, mut new_id: impl FnMut() -> String) -> Plan {
184    let mut out = Plan { folders: Vec::new(), files: Vec::new() };
185    let mut stack = vec![(tree, parent.map(String::from))];
186    // Breadth-first keeps parents before children and siblings together.
187    let mut i = 0;
188    while i < stack.len() {
189        let (node, parent) = (stack[i].0, stack[i].1.clone());
190        let id = new_id();
191        let name = node.dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| node.dir.to_string_lossy().into_owned());
192        out.folders.push(NewFolder { id: id.clone(), name, parent });
193        out.files.extend(node.files.iter().map(|f| (f.clone(), id.clone())));
194        stack.extend(node.folders.iter().map(|c| (c, Some(id.clone()))));
195        i += 1;
196    }
197    out
198}
199
200impl Library {
201    /// Creates the folders of a [`Plan`] and writes `metadata.json` once.
202    /// Each parent must exist already or come earlier in `folders`. As in
203    /// Eagle, the new folders take no icon from their parent. Nothing is
204    /// changed when a parent is missing.
205    pub fn create_folders(&mut self, folders: &[NewFolder]) -> Result<()> {
206        let mut known: std::collections::HashSet<String> = self.folder_ids().into_iter().map(String::from).collect();
207        if let Some(bad) = folders.iter().find(|f| {
208            let ok = f.parent.as_ref().is_none_or(|p| known.contains(p));
209            known.insert(f.id.clone());
210            !ok
211        }) {
212            let parent = bad.parent.clone().unwrap_or_default();
213            return Err(Error::Import { path: self.root().to_path_buf(), reason: format!("no folder {parent}") });
214        }
215        let now = crate::edit::now_ms();
216        for f in folders {
217            let folder = Folder::new(f.id.clone(), f.name.clone(), now);
218            match &f.parent {
219                None => self.metadata.folders.push(folder),
220                Some(p) => {
221                    if let Some(parent) = find_mut(&mut self.metadata.folders, p) {
222                        parent.children.push(folder);
223                    }
224                }
225            }
226        }
227        self.save_metadata()
228    }
229}
230
231fn find_mut<'a>(folders: &'a mut [Folder], id: &str) -> Option<&'a mut Folder> {
232    for f in folders {
233        if f.id == id {
234            return Some(f);
235        }
236        if let Some(found) = find_mut(&mut f.children, id) {
237            return Some(found);
238        }
239    }
240    None
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn touch(p: &Path) {
248        fs::create_dir_all(p.parent().unwrap()).unwrap();
249        fs::write(p, b"x").unwrap();
250    }
251
252    /// `Photos/` with `a.png`, `b.png`, `.hidden`, `Thumbs.db`, a package
253    /// `deck.key/`, and subfolders `Trip 10/x.jpg`, `Trip 2/y.jpg`,
254    /// `Trip 2/Day 1/z.jpg` and an empty `Empty/`.
255    fn sample(root: &Path) -> PathBuf {
256        let top = root.join("Photos");
257        for f in ["b.png", "a.png", ".hidden", "Thumbs.db", "deck.key/Index.zip", "Trip 10/x.jpg", "Trip 2/y.jpg", "Trip 2/Day 1/z.jpg"] {
258            touch(&top.join(f));
259        }
260        fs::create_dir_all(top.join("Empty")).unwrap();
261        top
262    }
263
264    fn names(paths: &[PathBuf]) -> Vec<String> {
265        paths.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect()
266    }
267
268    #[test]
269    fn walk_keeps_structure_and_skips_junk() {
270        let tmp = tempfile::tempdir().unwrap();
271        let tree = walk(&sample(tmp.path()));
272        assert_eq!(names(&tree.files), ["a.png", "b.png", "deck.key"], "hidden files and junk are skipped; packages are files");
273        let subs: Vec<String> = tree.folders.iter().map(|t| t.dir.file_name().unwrap().to_string_lossy().into_owned()).collect();
274        assert_eq!(subs, ["Empty", "Trip 2", "Trip 10"], "numbers sort by value");
275        assert_eq!(names(&tree.folders[1].files), ["y.jpg"]);
276        assert_eq!(names(&tree.folders[1].folders[0].files), ["z.jpg"]);
277    }
278
279    #[test]
280    fn walk_does_not_follow_folder_links() {
281        let tmp = tempfile::tempdir().unwrap();
282        let top = tmp.path().join("Loop");
283        touch(&top.join("a.png"));
284        #[cfg(unix)]
285        std::os::unix::fs::symlink(&top, top.join("again")).unwrap();
286        #[cfg(windows)]
287        crate::paths::tests::junction(&top, &top.join("again"));
288        let tree = walk(&top);
289        assert_eq!(names(&tree.files), ["a.png"]);
290        assert!(tree.folders.is_empty());
291    }
292
293    #[test]
294    fn stats_count_levels_and_files() {
295        let tmp = tempfile::tempdir().unwrap();
296        let tree = walk(&sample(tmp.path()));
297        assert_eq!(tree.stats(), Stats { depth: 3, files: 6 }, "Photos / Trip 2 / Day 1; junk and hidden files don't count");
298        assert_eq!(names(&tree.all_files()), ["a.png", "b.png", "deck.key", "y.jpg", "z.jpg", "x.jpg"]);
299        assert_eq!(walk(&tmp.path().join("missing")).stats(), Stats { depth: 1, files: 0 });
300    }
301
302    #[test]
303    fn limits_follow_eagle() {
304        let v = |depth, files| verdict(Stats { depth, files });
305        assert_eq!(v(14, 1_999), Verdict::Import);
306        assert_eq!(v(1, 2_000), Verdict::Confirm(2_000));
307        assert_eq!(v(14, 99_999), Verdict::Confirm(99_999));
308        assert_eq!(v(15, 1), Verdict::Refuse);
309        assert_eq!(v(1, 100_000), Verdict::Refuse);
310    }
311
312    #[test]
313    fn deep_trees_are_refused() {
314        let tmp = tempfile::tempdir().unwrap();
315        let mut dir = tmp.path().join("L1");
316        for i in 2..=15 {
317            dir = dir.join(format!("L{i}"));
318        }
319        touch(&dir.join("deep.png"));
320        let tree = walk(&tmp.path().join("L1"));
321        assert_eq!(tree.stats(), Stats { depth: 15, files: 1 });
322        assert_eq!(verdict(tree.stats()), Verdict::Refuse);
323        assert_eq!(verdict(walk(&tmp.path().join("L1/L2")).stats()), Verdict::Import, "14 levels are fine");
324    }
325
326    #[test]
327    fn pixave_libraries_are_recognised() {
328        assert!(is_pixave_library(Path::new("/x/Mine.pxvlibrary")));
329        assert!(is_pixave_library(Path::new("/x/Mine.PXVLibrary")));
330        assert!(!is_pixave_library(Path::new("/x/Mine.library")));
331    }
332
333    #[test]
334    fn plan_maps_folders_and_files() {
335        let tmp = tempfile::tempdir().unwrap();
336        let tree = walk(&sample(tmp.path()));
337        let mut n = 0;
338        let plan = plan(&tree, Some("PARENT"), || {
339            n += 1;
340            format!("F{n}")
341        });
342        let folders: Vec<(&str, &str, Option<&str>)> = plan.folders.iter().map(|f| (f.id.as_str(), f.name.as_str(), f.parent.as_deref())).collect();
343        assert_eq!(
344            folders,
345            [
346                ("F1", "Photos", Some("PARENT")),
347                ("F2", "Empty", Some("F1")),
348                ("F3", "Trip 2", Some("F1")),
349                ("F4", "Trip 10", Some("F1")),
350                ("F5", "Day 1", Some("F3")),
351            ]
352        );
353        assert_eq!(plan.root(), Some("F1"));
354        let files: Vec<(String, &str)> = plan.files.iter().map(|(p, id)| (p.file_name().unwrap().to_string_lossy().into_owned(), id.as_str())).collect();
355        let expect = [("a.png", "F1"), ("b.png", "F1"), ("deck.key", "F1"), ("y.jpg", "F3"), ("x.jpg", "F4"), ("z.jpg", "F5")];
356        assert_eq!(files, expect.map(|(a, b)| (a.to_string(), b)));
357    }
358
359    #[test]
360    fn create_folders_builds_the_tree_in_one_write() {
361        let tmp = tempfile::tempdir().unwrap();
362        let root = tmp.path().join("T.library");
363        let mut lib = Library::create(&root).unwrap();
364        let parent = lib.create_folder(None, "Inbox").unwrap();
365        let tree = walk(&sample(tmp.path()));
366        let plan = plan(&tree, Some(&parent), crate::ids::new_id);
367        lib.create_folders(&plan.folders).unwrap();
368        let lib = Library::open(&root).unwrap();
369        let inbox = &lib.metadata.folders[0];
370        assert_eq!(inbox.children.len(), 1);
371        let photos = &inbox.children[0];
372        assert_eq!(photos.name, "Photos");
373        let kids: Vec<&str> = photos.children.iter().map(|f| f.name.as_str()).collect();
374        assert_eq!(kids, ["Empty", "Trip 2", "Trip 10"]);
375        assert_eq!(photos.children[1].children[0].name, "Day 1");
376
377        // A plan at the top level; a missing parent changes nothing.
378        let mut lib = Library::open(&root).unwrap();
379        let top = super::plan(&walk(&tmp.path().join("Photos/Trip 2")), None, crate::ids::new_id);
380        lib.create_folders(&top.folders).unwrap();
381        assert_eq!(lib.metadata.folders.last().unwrap().name, "Trip 2");
382        let before = lib.metadata.folders.len();
383        let bad = super::plan(&walk(&tmp.path().join("Photos")), Some("NOPE"), crate::ids::new_id);
384        assert!(lib.create_folders(&bad.folders).is_err());
385        assert_eq!(Library::open(&root).unwrap().metadata.folders.len(), before);
386    }
387}