1use std::collections::HashSet;
35use std::fs;
36use std::path::{Path, PathBuf};
37use std::sync::atomic::{AtomicBool, Ordering};
38
39use crate::error::{Error, Result};
40use crate::library::Library;
41use crate::model::{Folder, Item, TagGroup};
42
43#[derive(Debug, Clone, PartialEq)]
45pub enum Extra {
46 None,
48 Folder(Box<Folder>),
50 SmartFolder(serde_json::Value),
52 TagGroup(TagGroup),
54}
55
56#[derive(Debug, Clone)]
58pub struct AddPlan {
59 pub source: PathBuf,
61 pub target: PathBuf,
63 pub items: Vec<String>,
65 pub extra: Extra,
66 pub now: u64,
68}
69
70impl AddPlan {
71 pub fn items(source: &Library, target: &Path, ids: Vec<String>) -> AddPlan {
73 AddPlan { source: source.root().to_path_buf(), target: target.to_path_buf(), items: ids, extra: Extra::None, now: crate::edit::now_ms() }
74 }
75
76 pub fn folder(source: &Library, target: &Path, id: &str) -> Option<AddPlan> {
80 let folder = source.all_folders().into_iter().find(|f| f.id == id)?.clone();
81 let mut ids = HashSet::new();
82 folder.walk(&mut |f| {
83 ids.insert(f.id.clone());
84 });
85 let items = source.items().iter().filter(|i| !i.is_deleted && i.folders.iter().any(|f| ids.contains(f))).map(|i| i.id.clone()).collect();
86 Some(AddPlan { source: source.root().to_path_buf(), target: target.to_path_buf(), items, extra: Extra::Folder(Box::new(folder)), now: crate::edit::now_ms() })
87 }
88
89 pub fn smart_folder(source: &Library, target: &Path, id: &str) -> Option<AddPlan> {
91 let def = find_smart(&source.metadata.smart_folders, id)?.clone();
92 Some(AddPlan { source: source.root().to_path_buf(), target: target.to_path_buf(), items: Vec::new(), extra: Extra::SmartFolder(def), now: crate::edit::now_ms() })
93 }
94
95 pub fn tag_group(source: &Library, target: &Path, id: &str) -> Option<AddPlan> {
97 let group = source.metadata.tags_groups.iter().find(|g| g.id == id)?.clone();
98 Some(AddPlan { source: source.root().to_path_buf(), target: target.to_path_buf(), items: Vec::new(), extra: Extra::TagGroup(group), now: crate::edit::now_ms() })
99 }
100
101 fn with_folder(&self) -> Option<&Folder> {
104 match &self.extra {
105 Extra::Folder(f) => Some(f),
106 _ => None,
107 }
108 }
109}
110
111fn find_smart<'a>(list: &'a [serde_json::Value], id: &str) -> Option<&'a serde_json::Value> {
112 for v in list {
113 if v.get("id").and_then(|i| i.as_str()) == Some(id) {
114 return Some(v);
115 }
116 if let Some(found) = v.get("children").and_then(|c| c.as_array()).and_then(|c| find_smart(c, id)) {
117 return Some(found);
118 }
119 }
120 None
121}
122
123#[derive(Debug, thiserror::Error)]
125pub enum AddRefusal {
126 #[error("this is the library that is open; choose another one")]
127 SameLibrary,
128 #[error("no Eagle library was found there")]
129 NotALibrary,
130 #[error(transparent)]
131 Library(#[from] Error),
132}
133
134pub fn check_target(current: &Path, target: &Path) -> std::result::Result<(), AddRefusal> {
138 let same = current == target || matches!((current.canonicalize(), target.canonicalize()), (Ok(x), Ok(y)) if x == y);
139 if same {
140 return Err(AddRefusal::SameLibrary);
141 }
142 let meta = target.join("metadata.json");
143 if !meta.is_file() || !target.join("images").is_dir() {
144 return Err(AddRefusal::NotALibrary);
145 }
146 #[derive(serde::Deserialize)]
147 struct Version {
148 #[serde(rename = "applicationVersion", default)]
149 application_version: String,
150 }
151 let bytes = fs::read(&meta).map_err(|e| Error::io(&meta, e))?;
152 let v: Version = serde_json::from_slice(&bytes).map_err(|_| AddRefusal::NotALibrary)?;
153 if crate::library::is_newer_than_app(&v.application_version) {
154 return Err(Error::NewerLibrary { path: target.to_path_buf(), version: v.application_version }.into());
155 }
156 Ok(())
157}
158
159#[derive(Debug, Clone, Default, PartialEq)]
161pub struct Copied {
162 pub copied: Vec<String>,
164 pub present: Vec<String>,
166 pub failed: Vec<(String, String)>,
168}
169
170pub fn copy_items(plan: &AddPlan, cancel: &AtomicBool, mut progress: impl FnMut(usize, usize)) -> Copied {
175 let mut out = Copied::default();
176 let images = plan.target.join("images");
177 let total = plan.items.len();
178 let folder = plan.with_folder();
179 let manual = folder.is_some_and(|f| f.order_by.as_deref() == Some("MANUAL"));
180 for (index, id) in plan.items.iter().enumerate() {
181 if cancel.load(Ordering::Relaxed) {
182 break;
183 }
184 let name = format!("{id}.info");
185 let target = images.join(&name);
186 if target.exists() {
187 out.present.push(id.clone());
188 } else {
189 let tmp = images.join(format!(".{name}.adding"));
190 let _ = fs::remove_dir_all(&tmp);
191 let src = plan.source.join("images").join(&name);
192 let done = copy_dir(&src, &tmp)
193 .map_err(|e| e.to_string())
194 .and_then(|()| restamp(&tmp.join("metadata.json"), id, folder.is_some(), manual, plan.now + index as u64))
195 .and_then(|()| fs::rename(&tmp, &target).map_err(|e| e.to_string()));
196 match done {
197 Ok(()) => out.copied.push(id.clone()),
198 Err(e) => {
199 let _ = fs::remove_dir_all(&tmp);
200 out.failed.push((id.clone(), e));
201 }
202 }
203 }
204 progress(index + 1, total);
205 }
206 out
207}
208
209fn restamp(file: &Path, id: &str, keep_folders: bool, manual: bool, stamp: u64) -> std::result::Result<(), String> {
213 let text = fs::read_to_string(file).map_err(|e| e.to_string())?;
214 let mut item = Item::from_json_str(&text).map_err(|e| format!("its metadata.json can't be read ({e})"))?;
215 if item.id != id {
216 return Err("its metadata.json names another item".into());
217 }
218 if !keep_folders {
219 item.folders.clear();
220 }
221 if !manual {
222 item.modification_time = stamp;
223 }
224 crate::library::write_json(file, &item).map_err(|e| e.to_string())
225}
226
227fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
228 fs::create_dir_all(dst)?;
229 for entry in fs::read_dir(src)? {
230 let entry = entry?;
231 let to = dst.join(entry.file_name());
232 if entry.file_type()?.is_dir() {
233 copy_dir(&entry.path(), &to)?;
234 } else {
235 fs::copy(entry.path(), &to)?;
236 if let Ok(meta) = entry.metadata() {
237 let _ = filetime::set_file_mtime(&to, filetime::FileTime::from_last_modification_time(&meta));
238 }
239 }
240 }
241 Ok(())
242}
243
244#[derive(Debug, Clone, Default, PartialEq)]
246pub struct AddReport {
247 pub items: usize,
248 pub definition: bool,
250}
251
252impl Library {
253 pub fn finish_add(&mut self, plan: &AddPlan, copied: &[String]) -> Result<AddReport> {
260 let mut report = AddReport::default();
261 match &plan.extra {
262 Extra::None => {}
263 Extra::Folder(folder) => {
264 let mut known: HashSet<String> = self.folder_ids().into_iter().map(String::from).collect();
265 if !known.contains(&folder.id) {
266 let before = self.metadata.folders.len();
267 crate::library_merge::merge_folder_into(&mut self.metadata.folders, (**folder).clone(), &mut known);
268 if let Some(pos) = self.metadata.folders.iter().skip(before).position(|f| f.id == folder.id) {
270 let f = self.metadata.folders.remove(before + pos);
271 self.metadata.folders.insert(0, f);
272 }
273 report.definition = true;
274 self.save_metadata()?;
275 }
276 }
277 Extra::SmartFolder(def) => {
278 let mut def = def.clone();
279 renew_smart_ids(&mut def);
280 self.metadata.smart_folders.insert(0, def);
281 report.definition = true;
282 self.save_metadata()?;
283 }
284 Extra::TagGroup(group) => {
285 let mut group = group.clone();
286 group.id = crate::ids::new_id();
287 self.metadata.tags_groups.insert(0, group);
288 report.definition = true;
289 self.save_metadata()?;
290 }
291 }
292 let folders: HashSet<String> = self.folder_ids().into_iter().map(String::from).collect();
293 let with_folder = plan.with_folder().is_some();
294 for id in copied {
295 let file = self.root().join("images").join(format!("{id}.info")).join("metadata.json");
296 let Ok(mut item) = fs::read_to_string(&file).map_err(|_| ()).and_then(|t| Item::from_json_str(&t).map_err(|_| ())) else { continue };
297 if item.id != *id || self.item(id).is_some() {
298 continue;
299 }
300 if with_folder && item.folders.iter().any(|f| !folders.contains(f)) {
301 item.folders.retain(|f| folders.contains(f));
302 crate::library::write_json(&file, &item)?;
303 }
304 let stamp = item.last_modified;
305 self.adopt(item, stamp);
306 report.items += 1;
307 }
308 if report.items > 0 {
309 self.mtime.all = Some(self.items().len() as u64);
310 self.write_mtime()?;
311 }
312 Ok(report)
313 }
314}
315
316fn renew_smart_ids(v: &mut serde_json::Value) {
319 if let Some(obj) = v.as_object_mut() {
320 obj.insert("id".into(), crate::ids::new_id().into());
321 if let Some(children) = obj.get_mut("children").and_then(|c| c.as_array_mut()) {
322 for c in children {
323 renew_smart_ids(c);
324 }
325 }
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::import::ImportOptions;
333
334 fn library(dir: &Path, name: &str) -> Library {
335 Library::create(dir.join(name)).unwrap()
336 }
337
338 fn import(lib: &mut Library, file: &str, folders: Vec<String>) -> String {
339 let src = lib.root().parent().unwrap().join(file);
340 fs::write(&src, file.as_bytes()).unwrap();
341 lib.import_file(&src, &ImportOptions { folders, ..Default::default() }).unwrap()
342 }
343
344 fn run(plan: &AddPlan, target: &mut Library) -> (Copied, AddReport) {
345 let copied = copy_items(plan, &AtomicBool::new(false), |_, _| {});
346 let report = target.finish_add(plan, &copied.copied).unwrap();
347 (copied, report)
348 }
349
350 #[test]
351 fn refuses_itself_and_non_libraries() {
352 let d = tempfile::tempdir().unwrap();
353 let a = library(d.path(), "A.library");
354 let _b = library(d.path(), "B.library");
355 assert!(matches!(check_target(a.root(), a.root()), Err(AddRefusal::SameLibrary)));
356 fs::create_dir_all(d.path().join("Plain")).unwrap();
357 assert!(matches!(check_target(a.root(), &d.path().join("Plain")), Err(AddRefusal::NotALibrary)));
358 assert!(check_target(a.root(), &d.path().join("B.library")).is_ok());
359 }
360
361 #[test]
362 fn items_are_copied_without_folders_and_restamped() {
363 let d = tempfile::tempdir().unwrap();
364 let mut a = library(d.path(), "A.library");
365 let mut b = library(d.path(), "B.library");
366 let f = a.create_folder(None, "F").unwrap();
367 let one = import(&mut a, "one.txt", vec![f.clone()]);
368 let two = import(&mut a, "two.txt", vec![]);
369 let before = fs::read_dir(a.root().join("images").join(format!("{one}.info"))).unwrap().count();
370 let mut plan = AddPlan::items(&a, b.root(), vec![two.clone(), one.clone()]);
371 plan.now = 1_000;
372 let (copied, report) = run(&plan, &mut b);
373 assert_eq!(copied.copied, [two.clone(), one.clone()]);
374 assert_eq!(report.items, 2);
375 let got = b.item(&one).unwrap();
376 assert!(got.folders.is_empty());
377 assert_eq!(got.modification_time, 1_001);
378 assert_eq!(b.item(&two).unwrap().modification_time, 1_000);
379 assert_eq!(fs::read_dir(b.root().join("images").join(format!("{one}.info"))).unwrap().count(), before);
380 assert_eq!(a.item(&one).unwrap().folders, [f]);
382 let reopened = Library::open(b.root()).unwrap();
384 assert_eq!(reopened.items().len(), 2);
385 assert!(reopened.item(&one).unwrap().folders.is_empty());
386 assert!(fs::read_dir(b.root().join("images")).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().starts_with('.')));
388 }
389
390 #[test]
391 fn items_already_there_are_left_alone() {
392 let d = tempfile::tempdir().unwrap();
393 let mut a = library(d.path(), "A.library");
394 let mut b = library(d.path(), "B.library");
395 let one = import(&mut a, "one.txt", vec![]);
396 let plan = AddPlan::items(&a, b.root(), vec![one.clone()]);
397 run(&plan, &mut b);
398 let stamp = b.item(&one).unwrap().modification_time;
399 let mut again = AddPlan::items(&a, b.root(), vec![one.clone()]);
400 again.now += 50_000;
401 let (copied, report) = run(&again, &mut b);
402 assert_eq!(copied.present, std::slice::from_ref(&one));
403 assert_eq!(report.items, 0);
404 assert_eq!(b.item(&one).unwrap().modification_time, stamp);
405 }
406
407 #[test]
408 fn cancel_stops_before_the_next_item() {
409 let d = tempfile::tempdir().unwrap();
410 let mut a = library(d.path(), "A.library");
411 let b = library(d.path(), "B.library");
412 let one = import(&mut a, "one.txt", vec![]);
413 let two = import(&mut a, "two.txt", vec![]);
414 let plan = AddPlan::items(&a, b.root(), vec![one.clone(), two]);
415 let cancel = AtomicBool::new(false);
416 let copied = copy_items(&plan, &cancel, |_, _| cancel.store(true, Ordering::Relaxed));
417 assert_eq!(copied.copied, [one]);
418 }
419
420 #[test]
421 fn a_damaged_item_fails_without_leaving_anything() {
422 let d = tempfile::tempdir().unwrap();
423 let mut a = library(d.path(), "A.library");
424 let mut b = library(d.path(), "B.library");
425 let one = import(&mut a, "one.txt", vec![]);
426 fs::write(a.root().join("images").join(format!("{one}.info")).join("metadata.json"), b"{").unwrap();
427 let plan = AddPlan::items(&a, b.root(), vec![one.clone()]);
428 let (copied, report) = run(&plan, &mut b);
429 assert_eq!(copied.failed.len(), 1);
430 assert_eq!(report.items, 0);
431 assert_eq!(fs::read_dir(b.root().join("images")).unwrap().count(), 0);
432 }
433
434 #[test]
435 fn a_folder_goes_to_the_top_with_its_items_and_their_folders() {
436 let d = tempfile::tempdir().unwrap();
437 let mut a = library(d.path(), "A.library");
438 let mut b = library(d.path(), "B.library");
439 let existing = b.create_folder(None, "Existing").unwrap();
440 let top = a.create_folder(None, "Top").unwrap();
441 let sub = a.create_folder(Some(&top), "Sub").unwrap();
442 let other = a.create_folder(None, "Other").unwrap();
443 let in_sub = import(&mut a, "sub.txt", vec![sub.clone(), other.clone()]);
444 let outside = import(&mut a, "out.txt", vec![other.clone()]);
445 let trashed = import(&mut a, "gone.txt", vec![top.clone()]);
446 a.set_trashed(std::slice::from_ref(&trashed), true).unwrap();
447 let plan = AddPlan::folder(&a, b.root(), &top).unwrap();
448 assert_eq!(plan.items, std::slice::from_ref(&in_sub));
449 let (_, report) = run(&plan, &mut b);
450 assert!(report.definition);
451 assert_eq!(report.items, 1);
452 let ids: Vec<&str> = b.metadata.folders.iter().map(|f| f.id.as_str()).collect();
453 assert_eq!(ids, [top.as_str(), existing.as_str()]);
454 assert_eq!(b.metadata.folders[0].children[0].id, sub);
455 assert_eq!(b.item(&in_sub).unwrap().folders, std::slice::from_ref(&sub));
457 assert!(b.item(&outside).is_none());
458 let again = AddPlan::folder(&a, b.root(), &top).unwrap();
460 let (_, report) = run(&again, &mut b);
461 assert!(!report.definition);
462 assert_eq!(b.metadata.folders.len(), 2);
463 let reopened = Library::open(b.root()).unwrap();
464 assert_eq!(reopened.metadata.folders[0].id, top);
465 }
466
467 #[test]
468 fn a_manually_sorted_folder_keeps_the_items_times() {
469 let d = tempfile::tempdir().unwrap();
470 let mut a = library(d.path(), "A.library");
471 let mut b = library(d.path(), "B.library");
472 let top = a.create_folder(None, "Top").unwrap();
473 let one = import(&mut a, "one.txt", vec![top.clone()]);
474 let stamp = a.item(&one).unwrap().modification_time;
475 let mut plan = AddPlan::folder(&a, b.root(), &top).unwrap();
476 if let Extra::Folder(f) = &mut plan.extra {
477 f.order_by = Some("MANUAL".into());
478 }
479 plan.now = stamp + 99_999;
480 run(&plan, &mut b);
481 assert_eq!(b.item(&one).unwrap().modification_time, stamp);
482 }
483
484 #[test]
485 fn smart_folders_and_tag_groups_get_new_ids_at_the_top() {
486 let d = tempfile::tempdir().unwrap();
487 let a = {
488 let mut a = library(d.path(), "A.library");
489 a.metadata.smart_folders.push(serde_json::json!({"id": "S1", "name": "Big", "conditions": [], "children": [{"id": "S2", "name": "Kid", "conditions": [], "children": []}]}));
490 a.metadata.tags_groups.push(TagGroup { id: "G1".into(), name: "Group".into(), tags: vec!["x".into()], color: None, description: None, extra: Default::default() });
491 a.save_metadata().unwrap();
492 a
493 };
494 let mut b = library(d.path(), "B.library");
495 b.metadata.tags_groups.push(TagGroup { id: "G0".into(), name: "Mine".into(), tags: vec![], color: None, description: None, extra: Default::default() });
496 b.save_metadata().unwrap();
497 let plan = AddPlan::smart_folder(&a, b.root(), "S1").unwrap();
498 assert!(run(&plan, &mut b).1.definition);
499 let sf = &b.metadata.smart_folders[0];
500 assert_eq!(sf["name"], "Big");
501 assert_ne!(sf["id"], "S1");
502 assert_ne!(sf["children"][0]["id"], "S2");
503 assert_eq!(sf["children"][0]["name"], "Kid");
504 let plan = AddPlan::tag_group(&a, b.root(), "G1").unwrap();
505 run(&plan, &mut b);
506 assert_eq!(b.metadata.tags_groups[0].name, "Group");
507 assert_ne!(b.metadata.tags_groups[0].id, "G1");
508 assert_eq!(b.metadata.tags_groups[1].id, "G0");
509 assert!(AddPlan::smart_folder(&a, b.root(), "nope").is_none());
510 assert!(AddPlan::smart_folder(&a, b.root(), "S2").is_some());
512 }
513}