Skip to main content

falcon_library/
autotag.rs

1//! Folder auto-tags: the tags stored in a folder's `tags` key, which Eagle
2//! adds to every item put into the folder.
3//!
4//! Eagle works out, for each folder, the folder's own tags followed by its
5//! parent's, its grandparent's and so on up to the top; it keeps the first
6//! copy of each tag and then reverses the whole list. That list (the
7//! runtime `extendTags`, which Eagle strips before writing `metadata.json`)
8//! is what gets applied, so a subfolder's items also get every ancestor's
9//! auto-tags. The cached `extendTags` key, when a file has one, is ignored
10//! here: the list is always worked out from `tags`.
11//!
12//! How the list is applied depends on the way the item arrives:
13//! importing (or pasting) into a folder appends the tags the item lacks;
14//! adding to a folder appends them only when the item was not already in
15//! it; dropping onto a folder appends them either way. Tags are never
16//! removed and never reordered.
17
18use std::collections::HashSet;
19
20use crate::error::{Error, Result};
21use crate::library::Library;
22use crate::model::{Folder, Item};
23
24/// The folders from the top level down to `id`, or `None` when no folder
25/// has that id.
26fn chain<'a>(folders: &'a [Folder], id: &str) -> Option<Vec<&'a Folder>> {
27    for f in folders {
28        if f.id == id {
29            return Some(vec![f]);
30        }
31        if let Some(mut below) = chain(&f.children, id) {
32            below.insert(0, f);
33            return Some(below);
34        }
35    }
36    None
37}
38
39/// The tags Eagle applies to an item put into folder `id`: the folder's
40/// own tags, then each ancestor's in turn up to the top level, with only
41/// the first copy of each kept, and the result reversed. Empty for an
42/// unknown folder.
43pub fn folder_auto_tags(folders: &[Folder], id: &str) -> Vec<String> {
44    let Some(chain) = chain(folders, id) else { return Vec::new() };
45    let mut seen = HashSet::new();
46    let mut out: Vec<String> = Vec::new();
47    for f in chain.iter().rev() {
48        for t in &f.tags {
49            if seen.insert(t.as_str()) {
50                out.push(t.clone());
51            }
52        }
53    }
54    out.reverse();
55    out
56}
57
58/// The auto-tags for an item going into several folders at once: each
59/// folder's list in the order given, first copy of each tag kept.
60pub fn auto_tags_for(folders: &[Folder], ids: &[String]) -> Vec<String> {
61    let mut out = Vec::new();
62    for id in ids {
63        append_new(&mut out, &folder_auto_tags(folders, id));
64    }
65    out
66}
67
68/// Appends the tags in `add` that `tags` lacks, in order. Returns whether
69/// any were added.
70pub fn append_new(tags: &mut Vec<String>, add: &[String]) -> bool {
71    let before = tags.len();
72    for t in add {
73        if !tags.contains(t) {
74            tags.push(t.clone());
75        }
76    }
77    tags.len() != before
78}
79
80/// Adds `item` to folder `id` as Eagle's add-to-folder does: when the item
81/// is not in the folder yet it joins it, comes out of the Trash and gets
82/// the folder's auto-tags. Returns false, with the item untouched, when it
83/// was already in the folder.
84pub fn join_folder(item: &mut Item, folders: &[Folder], id: &str) -> bool {
85    if item.folders.iter().any(|f| f == id) {
86        return false;
87    }
88    item.folders.push(id.to_string());
89    item.is_deleted = false;
90    append_new(&mut item.tags, &folder_auto_tags(folders, id));
91    true
92}
93
94/// Ids of folder `id` and every folder below it.
95fn subtree_ids(folders: &[Folder], id: &str) -> HashSet<String> {
96    let mut out = HashSet::new();
97    if let Some(f) = chain(folders, id).and_then(|c| c.last().copied()) {
98        f.walk(&mut |x| {
99            out.insert(x.id.clone());
100        });
101    }
102    out
103}
104
105impl Library {
106    /// Sets folder `id`'s auto-tags (its `tags` key) and, as Eagle's
107    /// auto-tag dialog does when the list changed, appends the folder's new
108    /// tags to every item in the folder or in any folder below it (trashed
109    /// items included). Tags taken off the folder stay on the items.
110    /// Duplicates in `tags` are dropped, keeping the first. Only `tags`
111    /// changes in `metadata.json`; the folder's `modificationTime` and any
112    /// cached `extendTags` are left alone. Returns how many items changed.
113    pub fn set_folder_auto_tags(&mut self, id: &str, tags: &[String]) -> Result<usize> {
114        let mut clean: Vec<String> = Vec::new();
115        append_new(&mut clean, tags);
116        let Some(folder) = chain(&self.metadata.folders, id).and_then(|c| c.last().copied()) else {
117            return Err(Error::Import { path: self.root().to_path_buf(), reason: format!("no folder {id}") });
118        };
119        if folder.tags == clean {
120            return Ok(0);
121        }
122        set_tags(&mut self.metadata.folders, id, clean.clone());
123        self.save_metadata()?;
124        let scope = subtree_ids(&self.metadata.folders, id);
125        let changed: Vec<Item> = self
126            .items()
127            .iter()
128            .filter(|i| i.folders.iter().any(|f| scope.contains(f)))
129            .filter_map(|i| {
130                let mut i = i.clone();
131                append_new(&mut i.tags, &clean).then_some(i)
132            })
133            .collect();
134        let n = changed.len();
135        for item in changed {
136            self.update_item(item)?;
137        }
138        Ok(n)
139    }
140}
141
142fn set_tags(folders: &mut [Folder], id: &str, tags: Vec<String>) {
143    for f in folders {
144        if f.id == id {
145            f.tags = tags;
146            return;
147        }
148        set_tags(&mut f.children, id, tags.clone());
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn folder(id: &str, tags: &[&str], children: Vec<Folder>) -> Folder {
157        let mut f = Folder::new(id, id, 0);
158        f.tags = tags.iter().map(|t| t.to_string()).collect();
159        f.children = children;
160        f
161    }
162
163    fn tree() -> Vec<Folder> {
164        vec![
165            folder("top", &["a", "b"], vec![folder("mid", &["c", "b"], vec![folder("leaf", &["d", "e"], vec![]), folder("bare", &[], vec![])])]),
166            folder("other", &["x"], vec![]),
167        ]
168    }
169
170    fn s(v: &[&str]) -> Vec<String> {
171        v.iter().map(|t| t.to_string()).collect()
172    }
173
174    #[test]
175    fn own_tags_then_ancestors_reversed() {
176        let t = tree();
177        assert_eq!(folder_auto_tags(&t, "top"), s(&["b", "a"]));
178        // [c, b] + [a, b] → first copies [c, b, a] → reversed.
179        assert_eq!(folder_auto_tags(&t, "mid"), s(&["a", "b", "c"]));
180        // [d, e] + [c, b] + [a, b] → [d, e, c, b, a] → reversed.
181        assert_eq!(folder_auto_tags(&t, "leaf"), s(&["a", "b", "c", "e", "d"]));
182        // A folder with no tags of its own still inherits.
183        assert_eq!(folder_auto_tags(&t, "bare"), s(&["a", "b", "c"]));
184        assert!(folder_auto_tags(&t, "missing").is_empty());
185    }
186
187    #[test]
188    fn several_folders_keep_first_copies() {
189        let t = tree();
190        assert_eq!(auto_tags_for(&t, &s(&["other", "mid"])), s(&["x", "a", "b", "c"]));
191        assert_eq!(auto_tags_for(&t, &s(&["top", "mid"])), s(&["b", "a", "c"]));
192        assert!(auto_tags_for(&t, &[]).is_empty());
193    }
194
195    #[test]
196    fn append_keeps_order_and_skips_present() {
197        let mut tags = s(&["b", "z"]);
198        assert!(append_new(&mut tags, &s(&["a", "b", "c"])));
199        assert_eq!(tags, s(&["b", "z", "a", "c"]));
200        assert!(!append_new(&mut tags, &s(&["a", "c"])));
201    }
202
203    fn item(folders: &[&str], tags: &[&str]) -> Item {
204        let mut i: Item = serde_json::from_str(r#"{"id":"I","name":"n","size":1,"btime":0,"mtime":0,"ext":"png","tags":[],"folders":[],"isDeleted":true,"url":"","annotation":"","modificationTime":0,"height":0,"width":0,"lastModified":0,"palettes":[]}"#).unwrap();
205        i.folders = s(folders);
206        i.tags = s(tags);
207        i
208    }
209
210    #[test]
211    fn joining_adds_tags_only_once() {
212        let t = tree();
213        let mut it = item(&["other"], &["c", "mine"]);
214        assert!(join_folder(&mut it, &t, "mid"));
215        assert_eq!(it.folders, s(&["other", "mid"]));
216        assert_eq!(it.tags, s(&["c", "mine", "a", "b"]));
217        assert!(!it.is_deleted);
218        // Already in the folder: nothing changes, even tags removed since.
219        it.tags = s(&["mine"]);
220        assert!(!join_folder(&mut it, &t, "mid"));
221        assert_eq!(it.tags, s(&["mine"]));
222    }
223
224    #[test]
225    fn setting_tags_reaches_items_below() {
226        let tmp = tempfile::tempdir().unwrap();
227        let mut lib = Library::create(tmp.path().join("A.library")).unwrap();
228        lib.metadata.folders = tree();
229        lib.save_metadata().unwrap();
230        for (id, folders) in [("A1", vec!["leaf"]), ("A2", vec!["top"]), ("A3", vec!["other"]), ("A4", vec!["bare"])] {
231            let mut i = item(&[], &["k"]);
232            i.id = id.into();
233            i.folders = s(&folders);
234            std::fs::create_dir_all(i.info_dir(lib.root())).unwrap();
235            std::fs::write(i.original_path(lib.root()), b"x").unwrap();
236            lib.register_item(i).unwrap();
237        }
238        // The new list goes to items in "mid" and below; "top" is above.
239        assert_eq!(lib.set_folder_auto_tags("mid", &s(&["n", "k", "n"])).unwrap(), 2);
240        assert_eq!(lib.item("A1").unwrap().tags, s(&["k", "n"]));
241        assert_eq!(lib.item("A4").unwrap().tags, s(&["k", "n"]));
242        assert_eq!(lib.item("A2").unwrap().tags, s(&["k"]));
243        assert_eq!(lib.item("A3").unwrap().tags, s(&["k"]));
244        let mid = &lib.metadata.folders[0].children[0];
245        assert_eq!((mid.tags.clone(), mid.extend_tags.clone()), (s(&["n", "k"]), None));
246        // The same list again changes nothing.
247        assert_eq!(lib.set_folder_auto_tags("mid", &s(&["n", "k"])).unwrap(), 0);
248        // Taking a tag off leaves it on the items.
249        assert_eq!(lib.set_folder_auto_tags("mid", &s(&["k"])).unwrap(), 0);
250        assert_eq!(lib.item("A1").unwrap().tags, s(&["k", "n"]));
251        let reopened = Library::open(lib.root()).unwrap();
252        assert_eq!(reopened.metadata.folders[0].children[0].tags, s(&["k"]));
253        assert!(lib.set_folder_auto_tags("missing", &[]).is_err());
254    }
255}