1use std::collections::HashSet;
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::sync::atomic::{AtomicBool, Ordering};
30
31use crate::error::{Error, Result};
32use crate::library::Library;
33use crate::model::{Folder, Item, LibraryMetadata};
34
35#[derive(Debug, Clone)]
37pub struct MergePlan {
38 pub source: PathBuf,
39 pub metadata: LibraryMetadata,
41 pub item_dirs: Vec<String>,
43}
44
45impl MergePlan {
46 pub fn counts(&self) -> (usize, usize) {
48 (self.metadata.folders.len(), self.item_dirs.len())
49 }
50}
51
52#[derive(Debug, thiserror::Error)]
54pub enum MergeRefusal {
55 #[error("this is the library that is open; choose another one")]
56 SameLibrary,
57 #[error("no Eagle library data (metadata.json) was found there")]
58 NotALibrary,
59 #[error(transparent)]
60 Library(#[from] Error),
61}
62
63pub fn plan(dest: &Library, source: &Path) -> std::result::Result<MergePlan, MergeRefusal> {
65 let same = |a: &Path, b: &Path| a == b || matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y);
66 if same(dest.root(), source) {
67 return Err(MergeRefusal::SameLibrary);
68 }
69 let meta_path = source.join("metadata.json");
70 if !meta_path.is_file() {
71 return Err(MergeRefusal::NotALibrary);
72 }
73 let bytes = fs::read(&meta_path).map_err(|e| Error::io(&meta_path, e))?;
74 let metadata: LibraryMetadata = serde_json::from_slice(&bytes).map_err(|_| MergeRefusal::NotALibrary)?;
75 if crate::library::is_newer_than_app(&metadata.application_version) {
76 return Err(Error::NewerLibrary { path: source.to_path_buf(), version: metadata.application_version }.into());
77 }
78 let images = source.join("images");
79 let mut item_dirs = Vec::new();
80 if images.is_dir() {
81 for entry in fs::read_dir(&images).map_err(|e| Error::io(&images, e))? {
82 let entry = entry.map_err(|e| Error::io(&images, e))?;
83 let name = entry.file_name().to_string_lossy().into_owned();
84 if name.ends_with(".info") && entry.path().is_dir() {
85 item_dirs.push(name);
86 }
87 }
88 }
89 item_dirs.sort();
90 Ok(MergePlan { source: source.to_path_buf(), metadata, item_dirs })
91}
92
93pub fn copy_items(dest_root: &Path, plan: &MergePlan, cancel: &AtomicBool, mut progress: impl FnMut(usize, usize)) -> (Vec<String>, Vec<(String, String)>) {
99 let images = dest_root.join("images");
100 let total = plan.item_dirs.len();
101 let (mut copied, mut failed) = (Vec::new(), Vec::new());
102 for (i, name) in plan.item_dirs.iter().enumerate() {
103 if cancel.load(Ordering::Relaxed) {
104 break;
105 }
106 let target = images.join(name);
107 if !target.exists() {
108 let tmp = images.join(format!(".{name}.merging"));
109 let _ = fs::remove_dir_all(&tmp);
110 match copy_dir(&plan.source.join("images").join(name), &tmp).and_then(|()| fs::rename(&tmp, &target)) {
111 Ok(()) => copied.push(name.clone()),
112 Err(e) => {
113 let _ = fs::remove_dir_all(&tmp);
114 failed.push((name.clone(), e.to_string()));
115 }
116 }
117 }
118 progress(i + 1, total);
119 }
120 (copied, failed)
121}
122
123fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
124 fs::create_dir_all(dst)?;
125 for entry in fs::read_dir(src)? {
126 let entry = entry?;
127 let to = dst.join(entry.file_name());
128 if entry.file_type()?.is_dir() {
129 copy_dir(&entry.path(), &to)?;
130 } else {
131 fs::copy(entry.path(), &to)?;
132 if let Ok(meta) = entry.metadata() {
133 let _ = filetime::set_file_mtime(&to, filetime::FileTime::from_last_modification_time(&meta));
134 }
135 }
136 }
137 Ok(())
138}
139
140#[derive(Debug, Clone, Default, PartialEq)]
142pub struct MergeReport {
143 pub folders: usize,
144 pub smart_folders: usize,
145 pub tag_groups: usize,
146 pub items: usize,
147}
148
149impl Library {
150 pub fn finish_merge(&mut self, plan: &MergePlan, copied: &[String]) -> Result<MergeReport> {
155 let mut report = MergeReport::default();
156 let mut known: HashSet<String> = self.folder_ids().into_iter().map(String::from).collect();
157 for folder in &plan.metadata.folders {
158 report.folders += merge_folder(&mut self.metadata.folders, None, folder.clone(), &mut known);
159 }
160 let smart_id = |v: &serde_json::Value| v.get("id").and_then(|i| i.as_str()).map(String::from);
161 let have: HashSet<String> = self.metadata.smart_folders.iter().filter_map(smart_id).collect();
162 for sf in &plan.metadata.smart_folders {
163 if smart_id(sf).is_none_or(|id| !have.contains(&id)) {
164 self.metadata.smart_folders.push(sf.clone());
165 report.smart_folders += 1;
166 }
167 }
168 let have: HashSet<String> = self.metadata.tags_groups.iter().map(|g| g.id.clone()).collect();
169 for g in &plan.metadata.tags_groups {
170 if !have.contains(&g.id) {
171 self.metadata.tags_groups.push(g.clone());
172 report.tag_groups += 1;
173 }
174 }
175 self.save_metadata()?;
176
177 for name in copied {
178 let id = name.trim_end_matches(".info");
179 let file = self.root().join("images").join(name).join("metadata.json");
180 let Ok(item) = fs::read_to_string(&file).map_err(|_| ()).and_then(|t| Item::from_json_str(&t).map_err(|_| ())) else { continue };
181 if item.id != id || self.item(id).is_some() {
182 continue;
183 }
184 let stamp = item.last_modified;
185 self.adopt(item, stamp);
186 report.items += 1;
187 }
188 if report.items > 0 {
189 self.mtime.all = Some(self.items().len() as u64);
190 self.write_mtime()?;
191 }
192 Ok(report)
193 }
194}
195
196pub(crate) fn merge_folder_into(tree: &mut Vec<Folder>, folder: Folder, known: &mut HashSet<String>) -> usize {
198 merge_folder(tree, None, folder, known)
199}
200
201fn merge_folder(tree: &mut Vec<Folder>, parent: Option<&str>, mut folder: Folder, known: &mut HashSet<String>) -> usize {
208 if known.contains(&folder.id) {
209 let id = folder.id.clone();
210 return std::mem::take(&mut folder.children).into_iter().map(|c| merge_folder(tree, Some(&id), c, known)).sum();
211 }
212 let mut displaced = Vec::new();
213 strip_known(&mut folder, known, &mut displaced);
214 folder.walk(&mut |f| {
215 known.insert(f.id.clone());
216 });
217 match parent.and_then(|p| find_mut(tree, p)) {
218 Some(p) => p.children.push(folder),
219 None => tree.push(folder),
220 }
221 for d in displaced {
222 merge_folder(tree, None, d, known);
223 }
224 1
225}
226
227fn strip_known(folder: &mut Folder, known: &HashSet<String>, displaced: &mut Vec<Folder>) {
228 let (dup, keep): (Vec<Folder>, Vec<Folder>) = std::mem::take(&mut folder.children).into_iter().partition(|c| known.contains(&c.id));
229 displaced.extend(dup);
230 folder.children = keep;
231 for c in &mut folder.children {
232 strip_known(c, known, displaced);
233 }
234}
235
236fn find_mut<'a>(folders: &'a mut [Folder], id: &str) -> Option<&'a mut Folder> {
237 for f in folders {
238 if f.id == id {
239 return Some(f);
240 }
241 if let Some(found) = find_mut(&mut f.children, id) {
242 return Some(found);
243 }
244 }
245 None
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::import::ImportOptions;
252
253 fn library(dir: &Path, name: &str) -> Library {
254 Library::create(dir.join(name)).unwrap()
255 }
256
257 fn import(lib: &mut Library, file: &str, folders: Vec<String>) -> String {
258 let src = lib.root().parent().unwrap().join(file);
259 fs::write(&src, file.as_bytes()).unwrap();
260 lib.import_file(&src, &ImportOptions { folders, ..Default::default() }).unwrap()
261 }
262
263 #[test]
264 fn refuses_itself_and_non_libraries() {
265 let d = tempfile::tempdir().unwrap();
266 let lib = library(d.path(), "A.library");
267 assert!(matches!(plan(&lib, lib.root()), Err(MergeRefusal::SameLibrary)));
268 fs::create_dir(d.path().join("plain")).unwrap();
269 assert!(matches!(plan(&lib, &d.path().join("plain")), Err(MergeRefusal::NotALibrary)));
270 }
271
272 #[test]
273 fn merges_folders_items_and_groups_by_id() {
274 let d = tempfile::tempdir().unwrap();
275 let mut dest = library(d.path(), "Dest.library");
276 let shared = dest.create_folder(None, "Shared").unwrap();
277 let mine = import(&mut dest, "mine.txt", vec![shared.clone()]);
278
279 dest.save_to(d.path().join("Other.library")).unwrap();
281 let mut other = Library::open(d.path().join("Other.library")).unwrap();
282 let sub = other.create_folder(Some(&shared), "Sub").unwrap();
283 let fresh = other.create_folder(None, "Fresh").unwrap();
284 let theirs = import(&mut other, "theirs.txt", vec![sub.clone(), fresh.clone()]);
285
286 let plan = plan(&dest, other.root()).unwrap();
287 assert_eq!(plan.counts(), (2, 2));
288 let cancel = AtomicBool::new(false);
289 let mut seen = Vec::new();
290 let (copied, failed) = copy_items(dest.root(), &plan, &cancel, |a, b| seen.push((a, b)));
291 assert!(failed.is_empty());
292 assert_eq!(copied, vec![format!("{theirs}.info")], "the shared item stays as it is");
293 assert_eq!(seen.last(), Some(&(2, 2)));
294
295 let report = dest.finish_merge(&plan, &copied).unwrap();
296 assert_eq!((report.folders, report.items), (2, 1), "Sub inside Shared, and Fresh");
297 let shared_f = dest.all_folders().into_iter().find(|f| f.id == shared).unwrap().clone();
298 assert_eq!(shared_f.children.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![sub.as_str()]);
299 assert_eq!(dest.metadata.folders.len(), 2);
300 assert_eq!(dest.item(&theirs).unwrap().folders, vec![sub, fresh]);
301 assert!(dest.item(&mine).is_some());
302 let again = Library::open(dest.root()).unwrap();
304 assert_eq!(again.items().len(), 2);
305 assert_eq!(again.mtime.all, Some(2));
306 assert!(!dest.root().join("images").read_dir().unwrap().any(|e| e.unwrap().file_name().to_string_lossy().ends_with(".merging")));
307 }
308
309 #[test]
310 fn a_new_folder_never_duplicates_an_id() {
311 let mut known: HashSet<String> = ["B".to_string()].into();
312 let mut tree = vec![Folder::new("X", "X", 0)];
313 tree[0].children.push(Folder::new("B", "B", 0));
314 let mut a = Folder::new("A", "A", 0);
315 let mut b = Folder::new("B", "B again", 0);
316 b.children.push(Folder::new("C", "C", 0));
317 a.children.push(b);
318 assert_eq!(merge_folder(&mut tree, None, a, &mut known), 1);
319 let mut ids = Vec::new();
320 for f in &tree {
321 f.walk(&mut |x| ids.push(x.id.clone()));
322 }
323 ids.sort();
324 assert_eq!(ids, vec!["A", "B", "C", "X"]);
325 assert_eq!(tree[0].children[0].children[0].id, "C");
327 }
328}