Skip to main content

falcon_library/
add_to_library.rs

1//! Adding items, a folder, a smart folder or a tag group of the open library
2//! to another library (Eagle's "Add to Other Library").
3//!
4//! What Eagle does, which falcon follows:
5//! - items: each `images/<id>.info` folder is copied whole into the other
6//!   library. One whose id the other library already has is left alone (and
7//!   counted as already there). The copy's `metadata.json` loses its
8//!   `folders` (they belong to this library), and its `modificationTime`
9//!   becomes now plus the item's position, so the items show up as newly
10//!   added in their order.
11//! - a folder: the folder (with its subfolders, ids kept) is put at the top
12//!   of the other library's tree unless that library already has its id;
13//!   subfolders whose ids it already has are not added a second time. Its
14//!   items and its subfolders' items are copied as above but keep their
15//!   `folders`, and in a folder sorted by hand they also keep their
16//!   `modificationTime`.
17//! - a smart folder or a tag group: only the definition is added, at the
18//!   top of the list, under new ids (a smart folder's subfolders too).
19//!
20//! falcon differs in three small ways: an item copied with a folder keeps
21//! only the folder ids the other library has once the tree is merged (so no
22//! item points at a folder that isn't there); a new folder's descendants
23//! whose ids exist elsewhere in the other library are merged into those, as
24//! in [`crate::library_merge`]; and each item folder is copied under a
25//! temporary name, its metadata rewritten there, then renamed into place, so
26//! the other library never sees half an item. Eagle leaves the other
27//! library's `tags.json` alone, and so does falcon.
28//!
29//! The copy ([`copy_items`]) needs no lock on either library and runs on a
30//! background thread; [`Library::finish_add`] then merges the definitions
31//! into the other library and registers the copied items there. The caller
32//! checks the other library with [`check_target`] first.
33
34use 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/// What is added besides the items.
44#[derive(Debug, Clone, PartialEq)]
45pub enum Extra {
46    /// Only the items.
47    None,
48    /// A folder with its subtree; the items came from it.
49    Folder(Box<Folder>),
50    /// A smart folder's definition (the raw `smartFolders` entry).
51    SmartFolder(serde_json::Value),
52    /// A tag group.
53    TagGroup(TagGroup),
54}
55
56/// Everything one Add to Other Library copies.
57#[derive(Debug, Clone)]
58pub struct AddPlan {
59    /// This library's root.
60    pub source: PathBuf,
61    /// The other library's root.
62    pub target: PathBuf,
63    /// Item ids in the order they were chosen.
64    pub items: Vec<String>,
65    pub extra: Extra,
66    /// The time the copies are stamped with (ms).
67    pub now: u64,
68}
69
70impl AddPlan {
71    /// Selected items.
72    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    /// Folder `id` with its subfolders, and the items in any of them, as
77    /// Eagle takes them (trashed items left out). None if there is no such
78    /// folder.
79    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    /// Smart folder `id`'s definition. None if there is no such smart folder.
90    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    /// Tag group `id`. None if there is no such group.
96    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    /// Whether copied items keep their `folders` and (in a folder sorted by
102    /// hand) their `modificationTime`.
103    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/// Why the other library can't take the items.
124#[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
134/// Checks that `target` is another library falcon may write: not
135/// `current`, holding `metadata.json` and `images/`, and not made by a
136/// newer Eagle.
137pub 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/// How the item copies went.
160#[derive(Debug, Clone, Default, PartialEq)]
161pub struct Copied {
162    /// Ids copied into the other library, in order.
163    pub copied: Vec<String>,
164    /// Ids the other library already had (left alone).
165    pub present: Vec<String>,
166    /// Ids that couldn't be copied, with the reason (nothing was left behind).
167    pub failed: Vec<(String, String)>,
168}
169
170/// Copies the plan's item folders into the other library's `images/`,
171/// rewriting each copy's `metadata.json` as Eagle does before it is renamed
172/// into place. Calls `progress(done, total)` after each item and stops early
173/// when `cancel` is set (what was copied stays). Needs no library lock.
174pub 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
209/// Rewrites a copied item's `metadata.json`: without a folder its `folders`
210/// go; unless it came from a folder sorted by hand its `modificationTime`
211/// becomes `stamp`. Everything else is written back as read.
212fn 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/// What a finished Add to Other Library added.
245#[derive(Debug, Clone, Default, PartialEq)]
246pub struct AddReport {
247    pub items: usize,
248    /// Whether the folder, smart folder or tag group was added.
249    pub definition: bool,
250}
251
252impl Library {
253    /// The second half of Add to Other Library, on the other library: adds
254    /// the plan's folder, smart folder or tag group (saving `metadata.json`
255    /// only then), and registers the items [`copy_items`] copied, in memory
256    /// and in `mtime.json`. With a folder, a copied item's `folders` keep
257    /// only ids this library has once the tree is merged. A copied item
258    /// that can't be read is left on disk and counted out.
259    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                    // Eagle puts the new folder at the top of the tree.
269                    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
316/// Gives a smart folder definition and all its subfolders new ids, as Eagle
317/// does when it adds one to another library.
318fn 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        // The source is untouched.
381        assert_eq!(a.item(&one).unwrap().folders, [f]);
382        // Reopened from disk, the other library has the same items.
383        let reopened = Library::open(b.root()).unwrap();
384        assert_eq!(reopened.items().len(), 2);
385        assert!(reopened.item(&one).unwrap().folders.is_empty());
386        // No temporary folders are left.
387        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        // `Other` isn't in the other library, so the item drops it.
456        assert_eq!(b.item(&in_sub).unwrap().folders, std::slice::from_ref(&sub));
457        assert!(b.item(&outside).is_none());
458        // Adding the same folder again adds no second copy.
459        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        // A subfolder can be added on its own.
511        assert!(AddPlan::smart_folder(&a, b.root(), "S2").is_some());
512    }
513}