Skip to main content

falcon_library/
library_merge.rs

1//! Merging another Eagle library into the open one (Eagle's Library ▸
2//! Import Library).
3//!
4//! Eagle refuses the library that is open, asks with the other library's
5//! top-level folder count and the number of entries in its `images/`, then:
6//! - folders: a top-level folder whose id the open library already has is
7//!   not added again; its subfolders go through the same test inside the
8//!   existing one. New folders are added with their whole subtree.
9//! - smart folders and tag groups whose id is already present are skipped,
10//!   the rest are appended.
11//! - every `images/<id>.info` folder the open library lacks is copied
12//!   verbatim; ones it has are left alone. The items keep their folder ids,
13//!   which the merged tree now contains.
14//!
15//! falcon differs in two small ways: it copies only `*.info` folders (not
16//! stray files in `images/`), and a new folder's descendants whose ids
17//! already exist elsewhere in the open library are merged into those
18//! instead of being added a second time, so the tree never holds one id
19//! twice. Each item folder is copied under a temporary name and renamed
20//! into place, so an interrupted merge never leaves half an item.
21//!
22//! The copy ([`copy_items`]) needs no lock on the open library and runs on
23//! a background thread; [`Library::finish_merge`] then merges the folder
24//! tree and registers the copied items.
25
26use 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/// What merging `source` would bring in, read before asking.
36#[derive(Debug, Clone)]
37pub struct MergePlan {
38    pub source: PathBuf,
39    /// The other library's `metadata.json`.
40    pub metadata: LibraryMetadata,
41    /// Its `images/<id>.info` folder names, sorted.
42    pub item_dirs: Vec<String>,
43}
44
45impl MergePlan {
46    /// The counts Eagle's confirmation shows: top-level folders, and items.
47    pub fn counts(&self) -> (usize, usize) {
48        (self.metadata.folders.len(), self.item_dirs.len())
49    }
50}
51
52/// Why a merge can't start.
53#[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
63/// Reads `source` to merge into `dest`.
64pub 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
93/// Copies the plan's item folders that `dest_root` lacks into its
94/// `images/`, calling `progress(done, total)` after each, and stops early
95/// when `cancel` is set. Returns the folder names copied. A folder that
96/// fails to copy is skipped (its partial copy removed) and reported in the
97/// second list with the reason.
98pub 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/// What a finished merge added.
141#[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    /// Merges the plan's folders, smart folders and tag groups into this
151    /// library (saving `metadata.json`), and registers the item folders
152    /// `copied` by [`copy_items`] in memory and `mtime.json`. Copied items
153    /// that can't be read are left on disk and counted out.
154    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
196/// [`merge_folder`] at the top level, for Add to Other Library.
197pub(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
201/// Merges `folder` into `tree` under `parent` (None: top level). A folder
202/// whose id the tree already has is not added; its children are merged
203/// into the existing folder of that id, wherever it is. A new folder is
204/// added with its subtree, minus descendants whose ids are already known,
205/// which are merged the same way. Returns how many folders were added (a
206/// subtree counts as one).
207fn 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        // The other library started as a copy (same folder id), then grew.
280        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        // Reopening reads the same library.
303        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        // C went into the existing B.
326        assert_eq!(tree[0].children[0].children[0].id, "C");
327    }
328}