Skip to main content

falcon_library/
tagging.rs

1//! Tag edits on selected items and the tag history, as Eagle's tag picker
2//! makes them.
3//!
4//! Eagle's picker adds tags one at a time: each goes onto every selected
5//! item that lacks it and then to the front of `historyTags`, so after
6//! adding several the last one leads the history. Taking a tag off items
7//! also drops it from the history once no item has it any more.
8
9use crate::Result;
10use crate::edit::HISTORY_LIMIT;
11use crate::library::Library;
12
13/// The longest tag Eagle keeps, in characters.
14pub const TAG_MAX_CHARS: usize = 1024;
15
16/// A typed tag as Eagle stores it: trimmed and cut to 1024 characters.
17/// `None` when nothing is left.
18pub fn clean(tag: &str) -> Option<String> {
19    let t: String = tag.trim().chars().take(TAG_MAX_CHARS).collect();
20    (!t.is_empty()).then_some(t)
21}
22
23/// Eagle's `addHistoryTag`: moves `tag` to the front of the history,
24/// which keeps at most 120 tags.
25pub fn push_history(history: &mut Vec<String>, tag: &str) {
26    if tag.is_empty() {
27        return;
28    }
29    history.retain(|h| h != tag);
30    history.insert(0, tag.to_string());
31    history.truncate(HISTORY_LIMIT);
32}
33
34impl Library {
35    /// Adds tags to items, as Eagle's picker does: each tag (cleaned with
36    /// [`clean`]) is appended to the items that lack it, then moved to the
37    /// front of `historyTags`. Returns how many items changed.
38    pub fn add_tags_to_items(&mut self, ids: &[String], tags: &[String]) -> Result<usize> {
39        let tags: Vec<String> = tags.iter().filter_map(|t| clean(t)).collect();
40        if tags.is_empty() || ids.is_empty() {
41            return Ok(0);
42        }
43        let mut changed = 0;
44        for id in ids {
45            let Some(mut item) = self.item(id).cloned() else { continue };
46            let before = item.tags.len();
47            for t in &tags {
48                if !item.tags.contains(t) {
49                    item.tags.push(t.clone());
50                }
51            }
52            if item.tags.len() != before {
53                self.update_item(item)?;
54                changed += 1;
55            }
56        }
57        for t in &tags {
58            push_history(&mut self.tags.history_tags, t);
59        }
60        self.save_tags()?;
61        Ok(changed)
62    }
63
64    /// Takes tags off items, as Eagle's picker and the × on a tag chip do.
65    /// A tag that no item outside the Trash has any more also leaves
66    /// `historyTags`. Returns how many items changed.
67    pub fn remove_tags_from_items(&mut self, ids: &[String], tags: &[String]) -> Result<usize> {
68        let mut changed = 0;
69        for id in ids {
70            let Some(mut item) = self.item(id).cloned() else { continue };
71            let before = item.tags.len();
72            item.tags.retain(|t| !tags.contains(t));
73            if item.tags.len() != before {
74                self.update_item(item)?;
75                changed += 1;
76            }
77        }
78        let gone: Vec<&String> = tags.iter().filter(|t| !self.items().iter().any(|i| !i.is_deleted && i.tags.contains(t))).collect();
79        if changed > 0 && gone.iter().any(|t| self.tags.history_tags.contains(t)) {
80            self.tags.history_tags.retain(|h| !gone.contains(&h));
81            self.save_tags()?;
82        }
83        Ok(changed)
84    }
85
86    /// Eagle's Paste Tags: appends the copied tags each item lacks, in
87    /// order, leaving the history alone. Returns how many items changed.
88    pub fn paste_tags(&mut self, ids: &[String], tags: &[String]) -> Result<usize> {
89        let mut changed = 0;
90        for id in ids {
91            let Some(mut item) = self.item(id).cloned() else { continue };
92            if crate::autotag::append_new(&mut item.tags, tags) {
93                self.update_item(item)?;
94                changed += 1;
95            }
96        }
97        Ok(changed)
98    }
99
100    /// Eagle's Clear Tags: empties the items' tags, leaving the history
101    /// alone. Returns how many items had tags.
102    pub fn clear_tags(&mut self, ids: &[String]) -> Result<usize> {
103        let mut changed = 0;
104        for id in ids {
105            let Some(mut item) = self.item(id).cloned() else { continue };
106            if !item.tags.is_empty() {
107                item.tags.clear();
108                self.update_item(item)?;
109                changed += 1;
110            }
111        }
112        Ok(changed)
113    }
114}
115
116/// The longest note Eagle keeps, in characters.
117pub const NOTE_MAX_CHARS: usize = 20480;
118
119/// Notes and links on several items at once, as Eagle's inspector edits
120/// them when more than one item is selected.
121impl Library {
122    /// Sets the note (`annotation`) of every item, cut to 20480 characters.
123    /// Returns how many items changed.
124    pub fn set_notes(&mut self, ids: &[String], note: &str) -> Result<usize> {
125        let note: String = note.chars().take(NOTE_MAX_CHARS).collect();
126        self.set_each(ids, |i| {
127            let changed = i.annotation != note;
128            if changed {
129                i.annotation = note.clone();
130            }
131            changed
132        })
133    }
134
135    /// Sets the source URL of every item. Returns how many items changed.
136    pub fn set_urls(&mut self, ids: &[String], url: &str) -> Result<usize> {
137        self.set_each(ids, |i| {
138            let changed = i.url != url;
139            if changed {
140                i.url = url.to_string();
141            }
142            changed
143        })
144    }
145
146    fn set_each(&mut self, ids: &[String], mut change: impl FnMut(&mut crate::model::Item) -> bool) -> Result<usize> {
147        let mut n = 0;
148        for id in ids {
149            let Some(mut item) = self.item(id).cloned() else { continue };
150            if change(&mut item) {
151                self.update_item(item)?;
152                n += 1;
153            }
154        }
155        Ok(n)
156    }
157}
158
159/// The tag manager's commands on several tags at once.
160impl Library {
161    /// Eagle's Merge Tags: renames each of `tags` to `into` everywhere
162    /// ([`Library::rename_tag`]: items, history, starred tags and groups),
163    /// so items keep one copy. Returns how many items changed.
164    pub fn merge_tags(&mut self, tags: &[String], into: &str) -> Result<usize> {
165        let into = into.trim();
166        if into.is_empty() {
167            return Ok(0);
168        }
169        let affected: std::collections::HashSet<String> =
170            self.items().iter().filter(|i| i.tags.iter().any(|t| t != into && tags.contains(t))).map(|i| i.id.clone()).collect();
171        for t in tags {
172            if t != into {
173                self.rename_tag(t, into)?;
174            }
175        }
176        Ok(affected.len())
177    }
178
179    /// Deletes several tags ([`Library::delete_tag`]). Returns how many
180    /// items changed.
181    pub fn delete_tags(&mut self, tags: &[String]) -> Result<usize> {
182        let affected = self.items().iter().filter(|i| i.tags.iter().any(|t| tags.contains(t))).count();
183        for t in tags {
184            self.delete_tag(t)?;
185        }
186        Ok(affected)
187    }
188
189    /// Stars several tags, the first one ending up first (Eagle's
190    /// `addStarredTags`), or unstars them.
191    pub fn star_tags(&mut self, tags: &[String], starred: bool) -> Result<()> {
192        self.tags.starred_tags.retain(|t| !tags.contains(t));
193        if starred {
194            let mut front: Vec<String> = Vec::new();
195            for t in tags {
196                if !front.contains(t) {
197                    front.push(t.clone());
198                }
199            }
200            front.append(&mut self.tags.starred_tags);
201            self.tags.starred_tags = front;
202        }
203        self.save_tags()
204    }
205
206    /// Eagle's Add to Group: files tags in a group as well, leaving them in
207    /// their other groups (where [`Library::add_tags_to_group`] moves them).
208    pub fn add_tags_to_group_too(&mut self, id: &str, tags: &[String]) -> Result<()> {
209        let Some(g) = self.metadata.tags_groups.iter_mut().find(|g| g.id == id) else {
210            return self.add_tags_to_group(id, tags);
211        };
212        for t in tags.iter().map(|t| t.replace(['\r', '\n'], "")) {
213            if !g.tags.contains(&t) {
214                g.tags.push(t);
215            }
216        }
217        self.save_metadata()
218    }
219
220    /// Empties `historyTags` (Eagle's Clear Tag History).
221    pub fn clear_tag_history(&mut self) -> Result<()> {
222        self.tags.history_tags.clear();
223        self.save_tags()
224    }
225}
226
227#[cfg(test)]
228pub(crate) mod tests {
229    use super::*;
230    use crate::model::Item;
231
232    pub(crate) fn s(v: &[&str]) -> Vec<String> {
233        v.iter().map(|x| x.to_string()).collect()
234    }
235
236    /// A library with items `A1`… holding the given tags.
237    pub(crate) fn library(dir: &std::path::Path, items: &[&[&str]]) -> Library {
238        let mut lib = Library::create(dir.join("T.library")).unwrap();
239        for (n, tags) in items.iter().enumerate() {
240            let mut i: Item = serde_json::from_str(r#"{"id":"I","name":"n","size":1,"btime":0,"mtime":0,"ext":"png","tags":[],"folders":[],"isDeleted":false,"url":"","annotation":"","modificationTime":0,"height":0,"width":0,"lastModified":0,"palettes":[]}"#).unwrap();
241            i.id = format!("A{}", n + 1);
242            i.tags = s(tags);
243            std::fs::create_dir_all(i.info_dir(lib.root())).unwrap();
244            std::fs::write(i.original_path(lib.root()), b"x").unwrap();
245            lib.register_item(i).unwrap();
246        }
247        lib
248    }
249
250    fn tags_json(lib: &Library) -> String {
251        std::fs::read_to_string(lib.root().join("tags.json")).unwrap()
252    }
253
254    #[test]
255    fn history_moves_to_front_and_caps() {
256        let mut h = s(&["a", "b", "c"]);
257        push_history(&mut h, "c");
258        assert_eq!(h, s(&["c", "a", "b"]));
259        push_history(&mut h, "");
260        assert_eq!(h.len(), 3);
261        for n in 0..200 {
262            push_history(&mut h, &format!("t{n}"));
263        }
264        assert_eq!(h.len(), HISTORY_LIMIT);
265        assert_eq!(h[0], "t199");
266    }
267
268    #[test]
269    fn cleaning_trims_and_cuts() {
270        assert_eq!(clean("  sky "), Some("sky".into()));
271        assert_eq!(clean("   "), None);
272        assert_eq!(clean(&"é".repeat(2000)).unwrap().chars().count(), TAG_MAX_CHARS);
273    }
274
275    #[test]
276    fn adding_appends_and_leads_history_with_the_last_tag() {
277        let tmp = tempfile::tempdir().unwrap();
278        let mut lib = library(tmp.path(), &[&["x"], &["b"]]);
279        lib.tags.history_tags = s(&["old", "b"]);
280        let n = lib.add_tags_to_items(&s(&["A1", "A2"]), &s(&[" a ", "b", "c"])).unwrap();
281        assert_eq!(n, 2);
282        assert_eq!(lib.item("A1").unwrap().tags, s(&["x", "a", "b", "c"]));
283        assert_eq!(lib.item("A2").unwrap().tags, s(&["b", "a", "c"]));
284        assert_eq!(lib.tags.history_tags, s(&["c", "b", "a", "old"]));
285        // Compact, keys in Eagle's order.
286        assert_eq!(tags_json(&lib), r#"{"historyTags":["c","b","a","old"],"starredTags":[]}"#);
287        // Tags every item has already still count as used.
288        assert_eq!(lib.add_tags_to_items(&s(&["A1"]), &s(&["x"])).unwrap(), 0);
289        assert_eq!(lib.tags.history_tags[0], "x");
290    }
291
292    #[test]
293    fn removing_drops_unused_tags_from_history() {
294        let tmp = tempfile::tempdir().unwrap();
295        let mut lib = library(tmp.path(), &[&["a", "b"], &["b"]]);
296        lib.tags.history_tags = s(&["a", "b", "z"]);
297        assert_eq!(lib.remove_tags_from_items(&s(&["A1"]), &s(&["a", "b"])).unwrap(), 1);
298        assert_eq!(lib.item("A1").unwrap().tags, Vec::<String>::new());
299        // `b` is still on A2; `z` was not touched.
300        assert_eq!(lib.tags.history_tags, s(&["b", "z"]));
301        assert_eq!(Library::open(lib.root()).unwrap().tags.history_tags, s(&["b", "z"]));
302    }
303
304    #[test]
305    fn merging_renames_into_one_name_everywhere() {
306        let tmp = tempfile::tempdir().unwrap();
307        let mut lib = library(tmp.path(), &[&["cat", "kitty", "x"], &["kitten"], &["dog"]]);
308        lib.record_tag_use(&s(&["kitty", "dog", "cat"])).unwrap();
309        lib.star_tags(&s(&["kitten"]), true).unwrap();
310        let g = lib.create_tag_group("Pets", &s(&["kitty", "dog", "cat"]), None).unwrap();
311        let n = lib.merge_tags(&s(&["cat", "kitty", "kitten"]), " cat ").unwrap();
312        assert_eq!(n, 2);
313        assert_eq!(lib.item("A1").unwrap().tags, s(&["cat", "x"]));
314        assert_eq!(lib.item("A2").unwrap().tags, s(&["cat"]));
315        assert_eq!(lib.item("A3").unwrap().tags, s(&["dog"]));
316        assert_eq!(lib.tags.history_tags, s(&["dog", "cat"]));
317        assert_eq!(lib.tags.starred_tags, s(&["cat"]));
318        assert_eq!(lib.metadata.tags_groups.iter().find(|x| x.id == g).unwrap().tags, s(&["dog", "cat"]));
319        assert_eq!(lib.merge_tags(&s(&["dog"]), "  ").unwrap(), 0, "an empty name does nothing");
320    }
321
322    #[test]
323    fn several_tags_at_once() {
324        let tmp = tempfile::tempdir().unwrap();
325        let mut lib = library(tmp.path(), &[&["a", "b", "c"], &["b"]]);
326        lib.star_tags(&s(&["z"]), true).unwrap();
327        lib.star_tags(&s(&["a", "b", "a"]), true).unwrap();
328        assert_eq!(lib.tags.starred_tags, s(&["a", "b", "z"]));
329        lib.star_tags(&s(&["a", "z"]), false).unwrap();
330        assert_eq!(lib.tags.starred_tags, s(&["b"]));
331        assert_eq!(lib.delete_tags(&s(&["a", "b"])).unwrap(), 2);
332        assert_eq!(lib.item("A1").unwrap().tags, s(&["c"]));
333        assert!(lib.tags.starred_tags.is_empty());
334
335        let one = lib.create_tag_group("One", &s(&["c"]), None).unwrap();
336        let two = lib.create_tag_group("Two", &[], None).unwrap();
337        lib.add_tags_to_group_too(&two, &s(&["c", "d\n", "c"])).unwrap();
338        let reopened = Library::open(lib.root()).unwrap();
339        let tags_of = |id: &str| reopened.metadata.tags_groups.iter().find(|g| g.id == id).unwrap().tags.clone();
340        assert_eq!(tags_of(&one), s(&["c"]), "the tag stays in its first group");
341        assert_eq!(tags_of(&two), s(&["c", "d"]));
342        assert!(lib.add_tags_to_group_too("nope", &s(&["c"])).is_err());
343
344        lib.record_tag_use(&s(&["c"])).unwrap();
345        lib.clear_tag_history().unwrap();
346        assert_eq!(tags_json(&lib), r#"{"historyTags":[],"starredTags":[]}"#);
347    }
348
349    #[test]
350    fn notes_and_urls_for_several_items() {
351        let tmp = tempfile::tempdir().unwrap();
352        let mut lib = library(tmp.path(), &[&[], &[], &[]]);
353        let two = s(&["A1", "A2"]);
354        assert_eq!(lib.set_notes(&two, "hello").unwrap(), 2);
355        assert_eq!(lib.set_notes(&two, "hello").unwrap(), 0, "unchanged items are not rewritten");
356        assert_eq!(lib.set_urls(&s(&["A2", "A3"]), "https://example.com/a").unwrap(), 2);
357        let reopened = Library::open(lib.root()).unwrap();
358        assert_eq!(reopened.item("A1").unwrap().annotation, "hello");
359        assert_eq!(reopened.item("A3").unwrap().annotation, "");
360        assert_eq!(reopened.item("A2").unwrap().url, "https://example.com/a");
361        assert_eq!(reopened.item("A1").unwrap().url, "");
362        lib.set_notes(&two, &"x".repeat(30000)).unwrap();
363        assert_eq!(lib.item("A1").unwrap().annotation.chars().count(), NOTE_MAX_CHARS);
364    }
365
366    #[test]
367    fn paste_appends_without_duplicates_and_clear_empties() {
368        let tmp = tempfile::tempdir().unwrap();
369        let mut lib = library(tmp.path(), &[&["b", "x"], &[], &["a", "b"]]);
370        lib.record_tag_use(&s(&["h"])).unwrap();
371        let all = s(&["A1", "A2", "A3"]);
372        assert_eq!(lib.paste_tags(&all, &s(&["a", "b"])).unwrap(), 2);
373        assert_eq!(lib.item("A1").unwrap().tags, s(&["b", "x", "a"]));
374        assert_eq!(lib.item("A2").unwrap().tags, s(&["a", "b"]));
375        assert_eq!(lib.item("A3").unwrap().tags, s(&["a", "b"]));
376        assert_eq!(lib.clear_tags(&s(&["A1", "A2", "missing"])).unwrap(), 2);
377        let reopened = Library::open(lib.root()).unwrap();
378        assert!(reopened.item("A1").unwrap().tags.is_empty());
379        assert_eq!(reopened.item("A3").unwrap().tags, s(&["a", "b"]));
380        assert_eq!(reopened.tags.history_tags, s(&["h"]), "paste and clear leave the history");
381        assert_eq!(lib.clear_tags(&s(&["A1"])).unwrap(), 0);
382    }
383}