1use crate::Result;
10use crate::edit::HISTORY_LIMIT;
11use crate::library::Library;
12
13pub const TAG_MAX_CHARS: usize = 1024;
15
16pub 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
23pub 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 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 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 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 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
116pub const NOTE_MAX_CHARS: usize = 20480;
118
119impl Library {
122 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 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
159impl Library {
161 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 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 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 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 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 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 assert_eq!(tags_json(&lib), r#"{"historyTags":["c","b","a","old"],"starredTags":[]}"#);
287 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 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}