1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct Tree {
23 pub dir: PathBuf,
25 pub files: Vec<PathBuf>,
27 pub folders: Vec<Tree>,
29}
30
31const PACKAGES: [&str; 3] = ["key", "pxd", "mindnode"];
34
35const JUNK: [&str; 4] = ["Thumbs.db", "ehthumbs.db", "Desktop.ini", "Icon\r"];
38
39pub 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
53pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Stats {
110 pub depth: usize,
112 pub files: usize,
114}
115
116pub const WARN_FILES: usize = 2_000;
118pub const MAX_FILES: usize = 100_000;
120pub const MAX_DEPTH: usize = 15;
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum Verdict {
127 Import,
129 Confirm(usize),
131 Refuse,
133}
134
135pub 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
147pub 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#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct NewFolder {
156 pub id: String,
158 pub name: String,
160 pub parent: Option<String>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct Plan {
167 pub folders: Vec<NewFolder>,
169 pub files: Vec<(PathBuf, String)>,
171}
172
173impl Plan {
174 pub fn root(&self) -> Option<&str> {
176 self.folders.first().map(|f| f.id.as_str())
177 }
178}
179
180pub 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 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 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 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 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}