Skip to main content

falcon_library/
edit.rs

1//! Editing items and tag history, with Eagle's on-disk effects.
2//!
3//! Every item edit rewrites the item's whole `metadata.json`, bumps
4//! `lastModified` (never `modificationTime`) and updates `mtime.json`.
5//! Renaming also renames the original and its thumbnail inside the `.info`
6//! folder; the folder itself keeps its ID name.
7
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::error::{Error, Result};
13use crate::import::sanitize_name;
14use crate::library::{Library, write_json};
15use crate::ids;
16use crate::model::{Comment, Folder, FolderOrder, ICON_COLORS, Item, SavedFilter, TagGroup};
17
18/// How many recent tags Eagle keeps in `historyTags`.
19pub const HISTORY_LIMIT: usize = 120;
20
21impl Library {
22    /// Saves `item` over the stored item with the same id.
23    pub fn update_item(&mut self, mut item: Item) -> Result<()> {
24        let old = self.item(&item.id).cloned().ok_or_else(|| Error::Import {
25            path: self.root().to_path_buf(),
26            reason: format!("no item {}", item.id),
27        })?;
28        if item.name != old.name {
29            item.name = sanitize_name(&item.name, &item.ext);
30        }
31        if item.name != old.name {
32            let root = self.root().to_path_buf();
33            let (from, to) = (old.original_path(&root), item.original_path(&root));
34            fs::rename(&from, &to).map_err(|e| Error::io(&from, e))?;
35            if let Some(thumb) = old.thumbnail_path(&root) {
36                let dest = thumb.with_file_name(item.thumbnail_name());
37                fs::rename(&thumb, &dest).map_err(|e| Error::io(&thumb, e))?;
38            }
39        }
40        item.last_modified = now_ms().max(old.last_modified + 1);
41        self.register_item(item)
42    }
43
44    /// Adds a comment to an item and returns its id. A missing id or
45    /// `lastModified` is filled in. Timed notes (`duration`) are kept sorted
46    /// by time, a new one after any at the same time; region notes are
47    /// appended. The item is saved like any other edit.
48    pub fn add_comment(&mut self, item_id: &str, mut comment: Comment) -> Result<String> {
49        let mut item = self.item(item_id).cloned().ok_or_else(|| self.invalid(format!("no item {item_id}")))?;
50        if comment.id.is_empty() {
51            comment.id = ids::new_id();
52        }
53        comment.last_modified.get_or_insert_with(now_ms);
54        let id = comment.id.clone();
55        let mut list = item.comments();
56        match comment.duration.as_ref().and_then(|d| d.as_f64()) {
57            Some(t) => {
58                let at = list.iter().position(|c| c.duration.as_ref().and_then(|d| d.as_f64()).is_some_and(|d| d > t)).unwrap_or(list.len());
59                list.insert(at, comment);
60            }
61            None => list.push(comment),
62        }
63        item.set_comments(&list);
64        self.update_item(item)?;
65        Ok(id)
66    }
67
68    /// Replaces the comment with `comment.id` on an item (text, region or
69    /// time). Eagle leaves the comment's `lastModified` alone on edits, so
70    /// whatever `comment` carries is written.
71    pub fn update_comment(&mut self, item_id: &str, comment: Comment) -> Result<()> {
72        let mut item = self.item(item_id).cloned().ok_or_else(|| self.invalid(format!("no item {item_id}")))?;
73        let mut list = item.comments();
74        let slot = list.iter_mut().find(|c| c.id == comment.id).ok_or_else(|| self.invalid(format!("no comment {}", comment.id)))?;
75        *slot = comment;
76        item.set_comments(&list);
77        self.update_item(item)
78    }
79
80    /// Removes a comment from an item. Eagle keeps an empty `comments: []`.
81    pub fn delete_comment(&mut self, item_id: &str, comment_id: &str) -> Result<()> {
82        let mut item = self.item(item_id).cloned().ok_or_else(|| self.invalid(format!("no item {item_id}")))?;
83        let mut list = item.comments();
84        let before = list.len();
85        list.retain(|c| c.id != comment_id);
86        if list.len() == before {
87            return Err(self.invalid(format!("no comment {comment_id}")));
88        }
89        item.set_comments(&list);
90        self.update_item(item)
91    }
92
93    /// Moves items to the trash (`isDeleted` plus `deletedTime`) or restores
94    /// them.
95    pub fn set_trashed(&mut self, ids: &[String], trashed: bool) -> Result<()> {
96        for id in ids {
97            let Some(mut item) = self.item(id).cloned() else { continue };
98            item.is_deleted = trashed;
99            if trashed {
100                item.extra.insert("deletedTime".into(), now_ms().into());
101            }
102            self.update_item(item)?;
103        }
104        Ok(())
105    }
106
107    /// Deletes items for good, as Eagle's permanent delete does: the whole
108    /// `.info` folder is removed and the id leaves `mtime.json`.
109    pub fn delete_permanently(&mut self, ids: &[String]) -> Result<()> {
110        for id in ids {
111            let Some(item) = self.item(id).cloned() else { continue };
112            let dir = item.info_dir(self.root());
113            if dir.is_dir() {
114                fs::remove_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
115            }
116            self.forget_item(id)?;
117        }
118        Ok(())
119    }
120
121    /// Permanently deletes everything in the trash and returns how many
122    /// items went.
123    pub fn empty_trash(&mut self) -> Result<usize> {
124        let ids: Vec<String> = self.items().iter().filter(|i| i.is_deleted).map(|i| i.id.clone()).collect();
125        self.delete_permanently(&ids)?;
126        Ok(ids.len())
127    }
128
129    /// Renames a tag on every item, merging it into `to` where an item has
130    /// both. Tag history, starred tags, tag groups, folders' auto-tags and
131    /// smart-folder tag rules follow, as in Eagle.
132    /// Returns how many items changed.
133    pub fn rename_tag(&mut self, from: &str, to: &str) -> Result<usize> {
134        let to = to.trim();
135        if to.is_empty() || from == to {
136            return Ok(0);
137        }
138        let affected: Vec<Item> = self.items().iter().filter(|i| i.tags.iter().any(|t| t == from)).cloned().collect();
139        let n = affected.len();
140        for mut item in affected {
141            let mut tags = Vec::with_capacity(item.tags.len());
142            for t in item.tags.drain(..) {
143                let t = if t == from { to.to_string() } else { t };
144                if !tags.contains(&t) {
145                    tags.push(t);
146                }
147            }
148            item.tags = tags;
149            self.update_item(item)?;
150        }
151        // As Eagle does: where the new name is listed already the old one
152        // goes, otherwise the new name takes the old one's place.
153        let swap = |list: &mut Vec<String>| {
154            if list.iter().any(|t| t == to) {
155                list.retain(|t| t != from);
156            } else {
157                for t in list.iter_mut() {
158                    if t == from {
159                        *t = to.to_string();
160                    }
161                }
162                let mut seen = std::collections::HashSet::new();
163                list.retain(|t| seen.insert(t.clone()));
164            }
165        };
166        swap(&mut self.tags.history_tags);
167        swap(&mut self.tags.starred_tags);
168        for g in &mut self.metadata.tags_groups {
169            swap(&mut g.tags);
170        }
171        // Folders' auto-tags and smart-folder tag rules follow too, as in
172        // Eagle: the first `from` becomes `to`, then duplicates go (the
173        // first copy stays).
174        let rename = |list: &mut Vec<String>| {
175            if let Some(i) = list.iter().position(|t| t == from) {
176                list[i] = to.to_string();
177                let mut seen = HashSet::new();
178                list.retain(|t| seen.insert(t.clone()));
179            }
180        };
181        walk_folders_mut(&mut self.metadata.folders, &mut |f| rename(&mut f.tags));
182        for sf in &mut self.metadata.smart_folders {
183            rename_in_smart_rules(sf, from, to);
184        }
185        self.save_metadata()?;
186        self.save_tags()?;
187        Ok(n)
188    }
189
190    /// Removes a tag from every item, the history, starred tags, groups and
191    /// folders' auto-tags.
192    pub fn delete_tag(&mut self, tag: &str) -> Result<usize> {
193        let affected: Vec<Item> = self.items().iter().filter(|i| i.tags.iter().any(|t| t == tag)).cloned().collect();
194        let n = affected.len();
195        for mut item in affected {
196            item.tags.retain(|t| t != tag);
197            self.update_item(item)?;
198        }
199        self.tags.history_tags.retain(|t| t != tag);
200        self.tags.starred_tags.retain(|t| t != tag);
201        for g in &mut self.metadata.tags_groups {
202            g.tags.retain(|t| t != tag);
203        }
204        // Eagle takes a deleted tag off folders' auto-tags as well (smart
205        // folder rules keep it).
206        walk_folders_mut(&mut self.metadata.folders, &mut |f| f.tags.retain(|t| t != tag));
207        self.save_metadata()?;
208        self.save_tags()?;
209        Ok(n)
210    }
211
212    /// Stars or unstars a tag (most recent first, as Eagle keeps them).
213    pub fn set_tag_starred(&mut self, tag: &str, starred: bool) -> Result<()> {
214        self.tags.starred_tags.retain(|t| t != tag);
215        if starred {
216            self.tags.starred_tags.insert(0, tag.to_string());
217        }
218        self.save_tags()
219    }
220
221    /// Records tags as just used: moved to the front of `historyTags`, which
222    /// is capped at 120, then writes `tags.json`.
223    pub fn record_tag_use(&mut self, tags: &[String]) -> Result<()> {
224        for t in tags.iter().rev() {
225            self.tags.history_tags.retain(|h| h != t);
226            self.tags.history_tags.insert(0, t.clone());
227        }
228        self.tags.history_tags.truncate(HISTORY_LIMIT);
229        self.save_tags()
230    }
231}
232
233/// Visits every folder in the tree, parents before children.
234fn walk_folders_mut(folders: &mut [Folder], f: &mut impl FnMut(&mut Folder)) {
235    for folder in folders {
236        f(folder);
237        walk_folders_mut(&mut folder.children, f);
238    }
239}
240
241/// Renames `from` to `to` in the `tags` rules of a smart folder entry and
242/// its children, in place so the entry's other keys keep their order. As
243/// Eagle does, the first `from` in a rule's list becomes `to` and
244/// duplicates are dropped after.
245fn rename_in_smart_rules(entry: &mut serde_json::Value, from: &str, to: &str) {
246    use serde_json::Value;
247    if let Some(conditions) = entry.get_mut("conditions").and_then(Value::as_array_mut) {
248        for c in conditions {
249            let Some(rules) = c.get_mut("rules").and_then(Value::as_array_mut) else { continue };
250            for r in rules {
251                if r.get("property").and_then(Value::as_str) != Some("tags") {
252                    continue;
253                }
254                let Some(list) = r.get_mut("value").and_then(Value::as_array_mut) else { continue };
255                if let Some(i) = list.iter().position(|v| v.as_str() == Some(from)) {
256                    list[i] = Value::String(to.to_string());
257                    let mut seen: Vec<Value> = Vec::with_capacity(list.len());
258                    list.retain(|v| {
259                        if seen.contains(v) {
260                            false
261                        } else {
262                            seen.push(v.clone());
263                            true
264                        }
265                    });
266                }
267            }
268        }
269    }
270    if let Some(children) = entry.get_mut("children").and_then(Value::as_array_mut) {
271        for c in children {
272            rename_in_smart_rules(c, from, to);
273        }
274    }
275}
276
277/// Tag groups, kept in `tagsGroups` of the library `metadata.json`. Group
278/// edits only rewrite that file; items are untouched unless a delete asks
279/// for its tags to go too.
280impl Library {
281    /// Adds a tag group at the end of the list and returns its id. Tags are
282    /// kept in the given order without duplicates; like Eagle's "create
283    /// group from tags", they are not taken out of other groups.
284    pub fn create_tag_group(&mut self, name: &str, tags: &[String], color: Option<&str>) -> Result<String> {
285        self.check_color(color)?;
286        let id = ids::new_id();
287        let mut list = Vec::new();
288        for t in tags.iter().map(|t| clean_tag(t)) {
289            if !list.contains(&t) {
290                list.push(t);
291            }
292        }
293        self.metadata.tags_groups.push(TagGroup {
294            id: id.clone(),
295            name: display_name(name),
296            tags: list,
297            color: color.map(str::to_string),
298            description: None,
299            extra: Default::default(),
300        });
301        self.save_metadata()?;
302        Ok(id)
303    }
304
305    /// Renames a tag group (cut to 1024 characters, as Eagle does).
306    pub fn rename_tag_group(&mut self, id: &str, name: &str) -> Result<()> {
307        self.tag_group_mut(id)?.name = display_name(name);
308        self.save_metadata()
309    }
310
311    /// Replaces a group's tags (order kept, duplicates dropped). Other
312    /// groups are left alone.
313    pub fn set_tag_group_tags(&mut self, id: &str, tags: &[String]) -> Result<()> {
314        let g = self.tag_group_mut(id)?;
315        g.tags.clear();
316        for t in tags.iter().map(|t| clean_tag(t)) {
317            if !g.tags.contains(&t) {
318                g.tags.push(t);
319            }
320        }
321        self.save_metadata()
322    }
323
324    /// Adds tags to the end of a group. As in Eagle's tag manager, a tag
325    /// moves: it leaves any other group it was in.
326    pub fn add_tags_to_group(&mut self, id: &str, tags: &[String]) -> Result<()> {
327        self.tag_group_mut(id)?;
328        let tags: Vec<String> = tags.iter().map(|t| clean_tag(t)).collect();
329        for g in &mut self.metadata.tags_groups {
330            if g.id == id {
331                for t in &tags {
332                    if !g.tags.contains(t) {
333                        g.tags.push(t.clone());
334                    }
335                }
336            } else {
337                g.tags.retain(|t| !tags.contains(t));
338            }
339        }
340        self.save_metadata()
341    }
342
343    /// Takes tags out of a group; the tags stay on their items.
344    pub fn remove_tags_from_group(&mut self, id: &str, tags: &[String]) -> Result<()> {
345        self.tag_group_mut(id)?.tags.retain(|t| !tags.contains(t));
346        self.save_metadata()
347    }
348
349    /// Sets a group's colour (one of [`ICON_COLORS`]) or removes it.
350    pub fn set_tag_group_color(&mut self, id: &str, color: Option<&str>) -> Result<()> {
351        self.check_color(color)?;
352        self.tag_group_mut(id)?.color = color.map(str::to_string);
353        self.save_metadata()
354    }
355
356    /// Sets a group's description, the text under its name in the tag
357    /// manager. Eagle keeps the key once set: clearing the box writes `""`
358    /// rather than removing it, and the key goes after `color`.
359    pub fn set_tag_group_description(&mut self, id: &str, description: &str) -> Result<()> {
360        self.tag_group_mut(id)?.description = Some(description.to_string());
361        self.save_metadata()
362    }
363
364    /// Deletes a group. With `remove_tags`, its tags are also deleted from
365    /// every item, the tag history and starred tags, as Eagle offers.
366    pub fn delete_tag_group(&mut self, id: &str, remove_tags: bool) -> Result<()> {
367        let pos = self.tag_group_index(id)?;
368        let group = self.metadata.tags_groups.remove(pos);
369        self.save_metadata()?;
370        if remove_tags {
371            for t in &group.tags {
372                self.delete_tag(t)?;
373            }
374        }
375        Ok(())
376    }
377
378    /// Moves a group to `index` in the list (after it has left its old
379    /// place; past the end appends), as dragging in the tag manager does.
380    pub fn reorder_tag_group(&mut self, id: &str, index: usize) -> Result<()> {
381        let pos = self.tag_group_index(id)?;
382        let g = self.metadata.tags_groups.remove(pos);
383        let at = index.min(self.metadata.tags_groups.len());
384        self.metadata.tags_groups.insert(at, g);
385        self.save_metadata()
386    }
387
388    fn tag_group_index(&self, id: &str) -> Result<usize> {
389        self.metadata.tags_groups.iter().position(|g| g.id == id).ok_or_else(|| self.invalid(format!("no tag group {id}")))
390    }
391
392    fn tag_group_mut(&mut self, id: &str) -> Result<&mut TagGroup> {
393        let pos = self.tag_group_index(id)?;
394        Ok(&mut self.metadata.tags_groups[pos])
395    }
396}
397
398/// Saved filters in `saved-filters.json`. Entries have no id, so they are
399/// addressed by position, as Eagle's menu does. Each edit rewrites the file
400/// (compact, no trailing newline) and leaves `metadata.json` alone.
401impl Library {
402    /// Appends a saved filter and returns its position. `rule` is the rule
403    /// object (see [`SavedFilter::rule_from_filter`]).
404    pub fn create_saved_filter(&mut self, name: &str, rule: serde_json::Value) -> Result<usize> {
405        if name.is_empty() {
406            return Err(self.invalid("empty saved filter name"));
407        }
408        self.saved_filters.push(SavedFilter { name: name.to_string(), rule, extra: Default::default() });
409        self.save_filters()?;
410        Ok(self.saved_filters.len() - 1)
411    }
412
413    /// Renames the saved filter at `index`; Eagle ignores empty names.
414    pub fn rename_saved_filter(&mut self, index: usize, name: &str) -> Result<()> {
415        if name.is_empty() {
416            return Err(self.invalid("empty saved filter name"));
417        }
418        self.saved_filter_mut(index)?.name = name.to_string();
419        self.save_filters()
420    }
421
422    /// Replaces the rules of the saved filter at `index`.
423    pub fn update_saved_filter(&mut self, index: usize, rule: serde_json::Value) -> Result<()> {
424        self.saved_filter_mut(index)?.rule = rule;
425        self.save_filters()
426    }
427
428    /// Deletes the saved filter at `index`.
429    pub fn delete_saved_filter(&mut self, index: usize) -> Result<()> {
430        self.saved_filter_mut(index)?;
431        self.saved_filters.remove(index);
432        self.save_filters()
433    }
434
435    /// Moves the saved filter at `index` to position `to` (past the end
436    /// appends), as dragging in Eagle's menu does.
437    pub fn reorder_saved_filter(&mut self, index: usize, to: usize) -> Result<()> {
438        self.saved_filter_mut(index)?;
439        let f = self.saved_filters.remove(index);
440        let at = to.min(self.saved_filters.len());
441        self.saved_filters.insert(at, f);
442        self.save_filters()
443    }
444
445    fn saved_filter_mut(&mut self, index: usize) -> Result<&mut SavedFilter> {
446        let err = self.invalid(format!("no saved filter {index}"));
447        self.saved_filters.get_mut(index).ok_or(err)
448    }
449
450    /// Writes `saved-filters.json` (or marks it dirty in batched mode),
451    /// recording the previous list in an open transaction.
452    pub(crate) fn save_filters(&mut self) -> Result<()> {
453        self.journal_saved_filters();
454        if self.batching(|d| d.saved_filters = true) {
455            return Ok(());
456        }
457        write_json(&self.root().join("saved-filters.json"), &self.saved_filters)
458    }
459}
460
461/// How [`Library::sort_subfolders`] orders folders by name.
462#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
463pub struct SubfolderSort {
464    /// Z to A instead of A to Z.
465    pub descending: bool,
466    /// Sort every level below as well, not just the direct subfolders.
467    pub all_levels: bool,
468}
469
470/// Eagle strips line breaks from tags it files into a group.
471fn clean_tag(t: &str) -> String {
472    t.replace(['\r', '\n'], "")
473}
474
475/// A name as Eagle stores it: cut to 1024 characters and NFC-normalised
476/// (Eagle normalises folder, smart-folder and tag-group names on save).
477pub(crate) fn display_name(name: &str) -> String {
478    use unicode_normalization::UnicodeNormalization;
479    name.chars().take(1024).nfc().collect()
480}
481
482impl Library {
483    /// Creates a folder at the end of `parent`'s children (or at the root)
484    /// and returns its id. A subfolder takes its parent's icon and colour,
485    /// as Eagle's new-subfolder command does.
486    pub fn create_folder(&mut self, parent: Option<&str>, name: &str) -> Result<String> {
487        let id = ids::new_id();
488        let mut folder = Folder::new(id.clone(), display_name(name), now_ms());
489        match parent {
490            None => self.metadata.folders.push(folder),
491            Some(p) => match find_mut(&mut self.metadata.folders, p) {
492                Some(f) => {
493                    folder.icon.clone_from(&f.icon);
494                    folder.icon_color.clone_from(&f.icon_color);
495                    f.children.push(folder)
496                }
497                None => return Err(self.missing(p)),
498            },
499        }
500        self.save_metadata()?;
501        Ok(id)
502    }
503
504    /// Renames a folder. Eagle leaves its `modificationTime` alone. A
505    /// cached `pinyin` (which Eagle itself strips before writing) would now
506    /// be stale, so it goes.
507    pub fn rename_folder(&mut self, id: &str, name: &str) -> Result<()> {
508        let Some(f) = find_mut(&mut self.metadata.folders, id) else { return Err(self.missing(id)) };
509        f.name = display_name(name);
510        f.pinyin = None;
511        self.save_metadata()
512    }
513
514    /// Sets a folder's own sort, or clears it with `None` (both `orderBy`
515    /// and `sortIncrease` go). Eagle leaves `modificationTime` alone.
516    pub fn set_folder_order(&mut self, id: &str, order: Option<FolderOrder>, increase: bool) -> Result<()> {
517        let Some(f) = find_mut(&mut self.metadata.folders, id) else { return Err(self.missing(id)) };
518        f.order_by = order.map(|o| o.as_str().to_string());
519        f.sort_increase = order.map(|_| increase);
520        self.save_metadata()
521    }
522
523    /// Sets a folder's icon and icon colour; `None` removes the key (the
524    /// default icon or colour). Pass the current value to keep one. The
525    /// colour must be one of [`ICON_COLORS`].
526    pub fn set_folder_icon(&mut self, id: &str, icon: Option<&str>, color: Option<&str>) -> Result<()> {
527        self.check_color(color)?;
528        let Some(f) = find_mut(&mut self.metadata.folders, id) else { return Err(self.missing(id)) };
529        f.icon = icon.map(str::to_string);
530        f.icon_color = color.map(str::to_string);
531        self.save_metadata()
532    }
533
534    /// Sets the item shown as a folder's cover, or removes `coverId`.
535    pub fn set_folder_cover(&mut self, id: &str, item: Option<&str>) -> Result<()> {
536        let Some(f) = find_mut(&mut self.metadata.folders, id) else { return Err(self.missing(id)) };
537        f.cover_id = item.map(str::to_string);
538        self.save_metadata()
539    }
540
541    /// Sets a folder's description.
542    pub fn set_folder_description(&mut self, id: &str, description: &str) -> Result<()> {
543        let Some(f) = find_mut(&mut self.metadata.folders, id) else { return Err(self.missing(id)) };
544        f.description = description.to_string();
545        self.save_metadata()
546    }
547
548    /// Locks a folder with `(password, hint)`, or unlocks it with `None`.
549    /// Eagle stores `btoa(password)`, which only accepts characters up to
550    /// U+00FF, so other passwords are refused here as Eagle cannot set them
551    /// either. Unlocking writes `""` for both keys, as Eagle's save does.
552    pub fn set_folder_password(&mut self, id: &str, password: Option<(&str, &str)>) -> Result<()> {
553        let encoded = match password {
554            Some(("", _)) => return Err(self.invalid("empty password")),
555            Some((p, hint)) => Some((encode_password(p).ok_or_else(|| self.invalid("password has characters beyond Latin-1"))?, hint)),
556            None => None,
557        };
558        let Some(f) = find_mut(&mut self.metadata.folders, id) else { return Err(self.missing(id)) };
559        let (password, tips) = encoded.unwrap_or_default();
560        f.password = password;
561        f.password_tips = tips.to_string();
562        self.save_metadata()
563    }
564
565    /// Whether `password` opens the folder. An unlocked or unknown folder
566    /// never matches.
567    pub fn verify_folder_password(&self, id: &str, password: &str) -> bool {
568        self.all_folders()
569            .into_iter()
570            .find(|f| f.id == id)
571            .is_some_and(|f| f.is_locked() && encode_password(password).is_some_and(|e| e == f.password))
572    }
573
574    /// Deletes a folder and its subfolders, moving items left in no folder
575    /// to the trash (see [`Library::delete_folders`]).
576    pub fn delete_folder(&mut self, id: &str) -> Result<()> {
577        self.delete_folders(&[id.to_string()], true)
578    }
579
580    /// Deletes folders with their subfolders, and unpins them from Quick
581    /// Access. Items that were in them lose those folder ids. With
582    /// `trash_items` (Eagle's "move the items to the trash too", on by
583    /// default), an item left in no folder goes to the trash, without a
584    /// `deletedTime` as Eagle does; an item still in another folder stays.
585    /// Without it every item stays. An id inside another deleted folder
586    /// goes with it; an unknown id is refused before anything changes.
587    pub fn delete_folders(&mut self, ids: &[String], trash_items: bool) -> Result<()> {
588        let known = self.folder_ids();
589        if let Some(id) = ids.iter().find(|id| !known.contains(id.as_str())) {
590            return Err(self.missing(id));
591        }
592        let mut gone = HashSet::new();
593        for id in ids {
594            if let Some(removed) = remove(&mut self.metadata.folders, id) {
595                removed.walk(&mut |f| {
596                    gone.insert(f.id.clone());
597                });
598            }
599        }
600        self.metadata
601            .quick_access
602            .retain(|q| q.get("id").and_then(|v| v.as_str()).is_none_or(|i| !gone.contains(i)));
603        self.save_metadata()?;
604        let affected: Vec<Item> = self
605            .items()
606            .iter()
607            .filter(|i| i.folders.iter().any(|f| gone.contains(f)))
608            .cloned()
609            .collect();
610        for mut item in affected {
611            item.folders.retain(|f| !gone.contains(f));
612            if trash_items && item.folders.is_empty() {
613                item.is_deleted = true;
614            }
615            self.update_item(item)?;
616        }
617        Ok(())
618    }
619
620    /// Duplicates a folder with its subfolders, right after it, as Eagle's
621    /// Duplicate does, and returns the copy's id. The copies keep the
622    /// names, description, auto-tags, icon, colour, cover and item sort;
623    /// each gets a new id and a new `modificationTime`, and loses its
624    /// password. No items are put in the copies.
625    pub fn duplicate_folder(&mut self, id: &str) -> Result<String> {
626        fn fresh(f: &mut Folder, now: u64) {
627            f.id = ids::new_id();
628            f.modification_time = now;
629            f.password = String::new();
630            f.password_tips = String::new();
631            for c in &mut f.children {
632                fresh(c, now);
633            }
634        }
635        let parent = parent_map(&self.metadata.folders).get(id).copied().ok_or_else(|| self.missing(id))?.map(str::to_string);
636        let mut copy = find_mut(&mut self.metadata.folders, id).expect("found above").clone();
637        fresh(&mut copy, now_ms());
638        let new_id = copy.id.clone();
639        let siblings = match parent {
640            None => &mut self.metadata.folders,
641            Some(p) => &mut find_mut(&mut self.metadata.folders, &p).expect("parent exists").children,
642        };
643        let at = siblings.iter().position(|f| f.id == id).expect("in its parent");
644        siblings.insert(at + 1, copy);
645        self.save_metadata()?;
646        Ok(new_id)
647    }
648
649    /// Renames several folders, `(id, new name)` each, in one save, as
650    /// Eagle's batch rename does. Like [`Library::rename_folder`] it leaves
651    /// `modificationTime` alone and drops a stale `pinyin`. An unknown id
652    /// is refused before anything changes.
653    pub fn rename_folders(&mut self, names: &[(String, String)]) -> Result<()> {
654        let known = self.folder_ids();
655        if let Some((id, _)) = names.iter().find(|(id, _)| !known.contains(id.as_str())) {
656            return Err(self.missing(id));
657        }
658        for (id, name) in names {
659            let f = find_mut(&mut self.metadata.folders, id).expect("checked above");
660            f.name = display_name(name);
661            f.pinyin = None;
662        }
663        self.save_metadata()
664    }
665
666    /// Gives several folders the same icon and/or colour in one save, as
667    /// Eagle's commands for selected folders do. `None` leaves that key as
668    /// each folder has it; `Some(None)` removes it.
669    pub fn restyle_folders(&mut self, ids: &[String], icon: Option<Option<&str>>, color: Option<Option<&str>>) -> Result<()> {
670        self.check_color(color.flatten())?;
671        self.expect_folders(ids)?;
672        for id in ids {
673            let f = find_mut(&mut self.metadata.folders, id).expect("checked above");
674            if let Some(i) = icon {
675                f.icon = i.map(str::to_string);
676            }
677            if let Some(c) = color {
678                f.icon_color = c.map(str::to_string);
679            }
680        }
681        self.save_metadata()
682    }
683
684    /// Gives several folders the same item sort in one save, as Eagle's
685    /// menu for selected folders does: `None` removes `orderBy` and
686    /// `sortIncrease`; a key keeps each folder's direction, ascending
687    /// when it had none.
688    pub fn set_folders_order(&mut self, ids: &[String], order: Option<FolderOrder>) -> Result<()> {
689        self.expect_folders(ids)?;
690        for id in ids {
691            let f = find_mut(&mut self.metadata.folders, id).expect("checked above");
692            f.order_by = order.map(|o| o.as_str().to_string());
693            f.sort_increase = order.map(|_| f.sort_increase.unwrap_or(true));
694        }
695        self.save_metadata()
696    }
697
698    /// Sets the sort direction of several folders in one save.
699    pub fn set_folders_increase(&mut self, ids: &[String], increase: bool) -> Result<()> {
700        self.expect_folders(ids)?;
701        for id in ids {
702            find_mut(&mut self.metadata.folders, id).expect("checked above").sort_increase = Some(increase);
703        }
704        self.save_metadata()
705    }
706
707    /// Moves several folders above, into or below `target` in one save, as
708    /// Eagle's Move Folder dialog does. They keep their order in the tree
709    /// (top to bottom); above and below they become the target's siblings
710    /// in that order, into it they follow its last child. A folder whose
711    /// parent moves too stays inside it. Moving onto one of the moved
712    /// folders, or into a moved folder's descendant, is refused, and
713    /// nothing changes on any error.
714    pub fn move_folders(&mut self, ids: &[String], target: &str, place: FolderPlace) -> Result<()> {
715        self.expect_folders(ids)?;
716        if !self.folder_ids().contains(target) {
717            return Err(self.missing(target));
718        }
719        let path = folder_path(&self.metadata.folders, target);
720        if ids.iter().any(|id| path.contains(&id.as_str())) {
721            return Err(self.invalid("cannot move a folder into itself"));
722        }
723        let moved = self.take_folders(ids);
724        let (siblings, at) = match place {
725            FolderPlace::Into => {
726                let kids = &mut find_mut(&mut self.metadata.folders, target).expect("checked above").children;
727                let end = kids.len();
728                (kids, end)
729            }
730            FolderPlace::Above | FolderPlace::Below => {
731                let parent = parent_map(&self.metadata.folders)[target].map(str::to_string);
732                let list = match parent {
733                    None => &mut self.metadata.folders,
734                    Some(p) => &mut find_mut(&mut self.metadata.folders, &p).expect("parent exists").children,
735                };
736                let i = list.iter().position(|f| f.id == target).expect("target is in its parent");
737                (list, if place == FolderPlace::Below { i + 1 } else { i })
738            }
739        };
740        siblings.splice(at..at, moved);
741        self.save_metadata()
742    }
743
744    /// Moves several folders to the end of the top level in one save, keeping
745    /// their tree order. A folder whose parent is among them stays inside it.
746    pub fn move_folders_to_top(&mut self, ids: &[String]) -> Result<()> {
747        self.expect_folders(ids)?;
748        let moved = self.take_folders(ids);
749        self.metadata.folders.extend(moved);
750        self.save_metadata()
751    }
752
753    /// Takes `ids`, known to exist, out of the tree in tree order, leaving out
754    /// folders whose parent moves with them.
755    fn take_folders(&mut self, ids: &[String]) -> Vec<Folder> {
756        let picked: HashSet<&str> = ids.iter().map(String::as_str).collect();
757        let parents = parent_map(&self.metadata.folders);
758        let movers: Vec<String> = self
759            .all_folders()
760            .into_iter()
761            .filter(|f| picked.contains(f.id.as_str()) && !parents[f.id.as_str()].is_some_and(|p| picked.contains(p)))
762            .map(|f| f.id.clone())
763            .collect();
764        movers.iter().map(|id| remove(&mut self.metadata.folders, id).expect("checked by the caller")).collect()
765    }
766
767    /// Moves a folder under `parent` (or to the root), at the end of its new
768    /// siblings as Eagle appends. Moving a folder into itself or one of its
769    /// descendants is refused.
770    pub fn move_folder(&mut self, id: &str, parent: Option<&str>) -> Result<()> {
771        self.reorder_folder(id, parent, usize::MAX)
772    }
773
774    /// Moves a folder under `new_parent` (or to the root) at position
775    /// `index` among its new siblings, counted after it has left its old
776    /// place; an index past the end appends. Use it with the same parent to
777    /// reorder siblings. Moving a folder into itself or one of its
778    /// descendants is refused, and nothing changes on any error.
779    pub fn reorder_folder(&mut self, id: &str, new_parent: Option<&str>, index: usize) -> Result<()> {
780        let Some(folder) = find_mut(&mut self.metadata.folders, id) else { return Err(self.missing(id)) };
781        if let Some(p) = new_parent {
782            let mut inside = false;
783            folder.walk(&mut |x| inside |= x.id == p);
784            if inside {
785                return Err(self.invalid("cannot move a folder into itself"));
786            }
787            if find_mut(&mut self.metadata.folders, p).is_none() {
788                return Err(self.missing(p));
789            }
790        }
791        let folder = remove(&mut self.metadata.folders, id).ok_or_else(|| self.missing(id))?;
792        let siblings = match new_parent {
793            None => &mut self.metadata.folders,
794            Some(p) => &mut find_mut(&mut self.metadata.folders, p).expect("parent checked above").children,
795        };
796        siblings.insert(index.min(siblings.len()), folder);
797        self.save_metadata()
798    }
799
800    /// Sorts `parent`'s subfolders (or the root folders) by name, as the
801    /// folder menu's "Sort By" commands do, and saves the reordered tree.
802    /// With `all_levels`, every folder below is sorted too. Names compare
803    /// case-insensitively with numbers by value; descending is the
804    /// ascending list reversed. Eagle stores no flag: only `children` (or
805    /// the root list) changes order.
806    pub fn sort_subfolders(&mut self, parent: Option<&str>, sort: SubfolderSort) -> Result<()> {
807        fn sort_level(list: &mut [Folder], sort: SubfolderSort) {
808            list.sort_by(|a, b| crate::search::natural_cmp(&a.name, &b.name));
809            if sort.descending {
810                list.reverse();
811            }
812            if sort.all_levels {
813                for f in list {
814                    sort_level(&mut f.children, sort);
815                }
816            }
817        }
818        let list = match parent {
819            None => &mut self.metadata.folders,
820            Some(p) => match find_mut(&mut self.metadata.folders, p) {
821                Some(f) => &mut f.children,
822                None => return Err(self.missing(p)),
823            },
824        };
825        sort_level(list, sort);
826        self.save_metadata()
827    }
828
829    /// Pins a folder (`"folder"`) or smart folder (`"smartFolder"`) to Quick
830    /// Access, at the end as Eagle does.
831    pub fn add_quick_access(&mut self, kind: &str, id: &str) -> Result<()> {
832        if !self.metadata.quick_access.iter().any(|q| q.get("id").and_then(|v| v.as_str()) == Some(id)) {
833            self.metadata.quick_access.push(serde_json::json!({"type": kind, "id": id}));
834        }
835        self.save_metadata()
836    }
837
838    /// Pins `id` (a `"folder"` or `"smartFolder"`) to Quick Access just
839    /// above `target`, or just below it when `below`, as Eagle does when an
840    /// entry or a folder is dropped on a pinned row. An id already pinned is
841    /// moved. A `target` that isn't pinned appends; dropping on itself does
842    /// nothing.
843    pub fn place_quick_access(&mut self, kind: &str, id: &str, target: &str, below: bool) -> Result<()> {
844        if id == target {
845            return Ok(());
846        }
847        let key = |q: &serde_json::Value| q.get("id").and_then(|v| v.as_str()).map(String::from);
848        // A pinned entry moves as it is (any other keys it has stay).
849        let entry = match self.metadata.quick_access.iter().position(|q| key(q).as_deref() == Some(id)) {
850            Some(i) => self.metadata.quick_access.remove(i),
851            None => serde_json::json!({"type": kind, "id": id}),
852        };
853        let at = self.metadata.quick_access.iter().position(|q| key(q).as_deref() == Some(target));
854        let at = at.map_or(self.metadata.quick_access.len(), |i| i + usize::from(below));
855        self.metadata.quick_access.insert(at, entry);
856        self.save_metadata()
857    }
858
859    /// Unpins an id from Quick Access.
860    pub fn remove_quick_access(&mut self, id: &str) -> Result<()> {
861        self.metadata.quick_access.retain(|q| q.get("id").and_then(|v| v.as_str()) != Some(id));
862        self.save_metadata()
863    }
864
865    /// Writes the library `metadata.json`, stamping its `modificationTime`.
866    /// An open transaction records the metadata as last saved; in batched
867    /// mode the write waits for [`Library::flush`].
868    pub fn save_metadata(&mut self) -> Result<()> {
869        self.metadata.modification_time = now_ms();
870        self.journal_metadata();
871        if self.batching(|d| d.metadata = true) {
872            return Ok(());
873        }
874        self.write_metadata_file()
875    }
876
877    /// Writes `tags.json` like [`Library::save_metadata`] writes metadata.
878    pub(crate) fn save_tags(&mut self) -> Result<()> {
879        self.journal_tags();
880        if self.batching(|d| d.tags = true) {
881            return Ok(());
882        }
883        write_json(&self.root().join("tags.json"), &self.tags)
884    }
885
886    /// Refuses a list naming a folder that doesn't exist.
887    fn expect_folders(&self, ids: &[String]) -> Result<()> {
888        let known = self.folder_ids();
889        match ids.iter().find(|id| !known.contains(id.as_str())) {
890            Some(id) => Err(self.missing(id)),
891            None => Ok(()),
892        }
893    }
894
895    fn missing(&self, id: &str) -> Error {
896        Error::Import { path: self.root().to_path_buf(), reason: format!("no folder {id}") }
897    }
898
899    pub(crate) fn invalid(&self, reason: impl Into<String>) -> Error {
900        Error::Import { path: self.root().to_path_buf(), reason: reason.into() }
901    }
902
903    /// Refuses a colour outside Eagle's palette.
904    pub(crate) fn check_color(&self, color: Option<&str>) -> Result<()> {
905        match color {
906            Some(c) if !ICON_COLORS.contains(&c) => Err(self.invalid(format!("unknown colour {c}"))),
907            _ => Ok(()),
908        }
909    }
910}
911
912/// Where [`Library::move_folders`] puts folders, next to the target.
913#[derive(Debug, Clone, Copy, PartialEq, Eq)]
914pub enum FolderPlace {
915    Above,
916    Into,
917    Below,
918}
919
920/// Each folder's parent id (`None` at the top level).
921fn parent_map(roots: &[Folder]) -> HashMap<&str, Option<&str>> {
922    fn go<'a>(list: &'a [Folder], parent: Option<&'a str>, out: &mut HashMap<&'a str, Option<&'a str>>) {
923        for f in list {
924            out.insert(&f.id, parent);
925            go(&f.children, Some(&f.id), out);
926        }
927    }
928    let mut out = HashMap::new();
929    go(roots, None, &mut out);
930    out
931}
932
933/// Ids from the top level down to `id`, `id` included.
934fn folder_path<'a>(roots: &'a [Folder], id: &str) -> Vec<&'a str> {
935    let parents = parent_map(roots);
936    let mut path = Vec::new();
937    let mut cur = parents.get_key_value(id).map(|(k, _)| *k);
938    while let Some(c) = cur {
939        path.push(c);
940        cur = parents[c];
941    }
942    path
943}
944
945fn find_mut<'a>(folders: &'a mut [Folder], id: &str) -> Option<&'a mut Folder> {
946    for f in folders {
947        if f.id == id {
948            return Some(f);
949        }
950        if let Some(found) = find_mut(&mut f.children, id) {
951            return Some(found);
952        }
953    }
954    None
955}
956
957fn remove(folders: &mut Vec<Folder>, id: &str) -> Option<Folder> {
958    if let Some(pos) = folders.iter().position(|f| f.id == id) {
959        return Some(folders.remove(pos));
960    }
961    folders.iter_mut().find_map(|f| remove(&mut f.children, id))
962}
963
964pub(crate) fn now_ms() -> u64 {
965    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
966}
967
968/// `btoa`: base64 of each character as one byte; `None` past U+00FF.
969fn encode_password(p: &str) -> Option<String> {
970    use base64::Engine;
971    let bytes: Option<Vec<u8>> = p.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect();
972    Some(base64::engine::general_purpose::STANDARD.encode(bytes?))
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    #[test]
980    fn subfolders_sort_by_name_with_numbers_by_value() {
981        let tmp = tempfile::tempdir().unwrap();
982        let mut lib = Library::create(tmp.path().join("S.library")).unwrap();
983        let top = lib.create_folder(None, "Top").unwrap();
984        for n in ["folder 10", "Folder 2", "beta", "Alpha", "folder 1"] {
985            let id = lib.create_folder(Some(&top), n).unwrap();
986            for c in ["z", "B", "a"] {
987                lib.create_folder(Some(&id), c).unwrap();
988            }
989        }
990        lib.create_folder(None, "Another").unwrap();
991        let names = |l: &[Folder]| l.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
992        let stamp = lib.metadata.folders[0].children[0].modification_time;
993
994        lib.sort_subfolders(Some(&top), SubfolderSort::default()).unwrap();
995        let reopened = Library::open(lib.root()).unwrap();
996        let kids = &reopened.metadata.folders[0].children;
997        assert_eq!(names(kids), ["Alpha", "beta", "folder 1", "Folder 2", "folder 10"]);
998        assert_eq!(names(&kids[0].children), ["z", "B", "a"], "one level only");
999        assert_eq!(names(&reopened.metadata.folders), ["Top", "Another"], "other levels untouched");
1000        assert!(kids.iter().any(|f| f.modification_time == stamp), "folders keep their times");
1001
1002        lib.sort_subfolders(None, SubfolderSort { descending: true, all_levels: true }).unwrap();
1003        assert_eq!(names(&lib.metadata.folders), ["Top", "Another"]);
1004        let kids = &lib.metadata.folders[0].children;
1005        assert_eq!(names(kids), ["folder 10", "Folder 2", "folder 1", "beta", "Alpha"]);
1006        assert_eq!(names(&kids[0].children), ["z", "B", "a"]);
1007        lib.sort_subfolders(None, SubfolderSort { descending: false, all_levels: true }).unwrap();
1008        assert_eq!(names(&lib.metadata.folders[1].children[0].children), ["a", "B", "z"]);
1009        assert!(lib.sort_subfolders(Some("NOPE"), SubfolderSort::default()).is_err());
1010    }
1011
1012    #[test]
1013    fn tag_group_descriptions_and_drag_order() {
1014        let tmp = tempfile::tempdir().unwrap();
1015        let mut lib = Library::create(tmp.path().join("G.library")).unwrap();
1016        let a = lib.create_tag_group("A", &["x".into()], Some("red")).unwrap();
1017        let b = lib.create_tag_group("B", &[], None).unwrap();
1018        let c = lib.create_tag_group("C", &[], None).unwrap();
1019        let path = lib.root().join("metadata.json");
1020        let text = || std::fs::read_to_string(&path).unwrap();
1021        assert!(!text().contains("description"), "no key until one is set");
1022        lib.set_tag_group_description(&a, "Warm & bright").unwrap();
1023        assert!(text().contains(&format!(r#"{{"id":"{a}","name":"A","tags":["x"],"color":"red","description":"Warm & bright"}}"#)), "{}", text());
1024        lib.set_tag_group_description(&a, "").unwrap();
1025        assert!(text().contains(r#""color":"red","description":""}"#), "cleared, the key stays: {}", text());
1026        assert!(lib.set_tag_group_description("NOPE", "x").is_err());
1027
1028        // Dragging C to the top, then A below B (index counted after removal).
1029        lib.reorder_tag_group(&c, 0).unwrap();
1030        lib.reorder_tag_group(&a, 2).unwrap();
1031        let order: Vec<&str> = lib.metadata.tags_groups.iter().map(|g| g.id.as_str()).collect();
1032        assert_eq!(order, [c.as_str(), b.as_str(), a.as_str()]);
1033        let reopened = Library::open(lib.root()).unwrap();
1034        assert_eq!(reopened.metadata.tags_groups[2].description.as_deref(), Some(""));
1035    }
1036
1037    /// A library with three imported PNGs.
1038    fn with_items(dir: &std::path::Path) -> (Library, Vec<String>) {
1039        let mut lib = Library::create(dir.join("B.library")).unwrap();
1040        let mut ids = Vec::new();
1041        for n in 0..3 {
1042            let path = dir.join(format!("{n}.png"));
1043            image::RgbImage::from_pixel(4, 4, image::Rgb([n * 80, 20, 20])).save(&path).unwrap();
1044            ids.push(lib.import_file(&path, &crate::import::ImportOptions::default()).unwrap());
1045        }
1046        (lib, ids)
1047    }
1048
1049    fn file_into(lib: &mut Library, item: &str, folders: &[&String]) {
1050        let mut i = lib.item(item).unwrap().clone();
1051        i.folders = folders.iter().map(|f| f.to_string()).collect();
1052        lib.update_item(i).unwrap();
1053    }
1054
1055    fn names(l: &[Folder]) -> Vec<String> {
1056        l.iter().map(|f| f.name.clone()).collect()
1057    }
1058
1059    #[test]
1060    fn deleting_several_folders_trashes_or_keeps_items() {
1061        let tmp = tempfile::tempdir().unwrap();
1062        let (mut lib, items) = with_items(tmp.path());
1063        let a = lib.create_folder(None, "A").unwrap();
1064        let a1 = lib.create_folder(Some(&a), "A1").unwrap();
1065        let b = lib.create_folder(None, "B").unwrap();
1066        let c = lib.create_folder(None, "C").unwrap();
1067        lib.add_quick_access("folder", &a1).unwrap();
1068        lib.add_quick_access("folder", &c).unwrap();
1069        file_into(&mut lib, &items[0], &[&a1]);
1070        file_into(&mut lib, &items[1], &[&a, &b]);
1071        file_into(&mut lib, &items[2], &[&b, &c]);
1072
1073        // Keeping the items: they only lose the folders.
1074        lib.delete_folders(&[a.clone(), b.clone()], false).unwrap();
1075        let kept = Library::open(lib.root()).unwrap();
1076        assert_eq!(names(&kept.metadata.folders), ["C"]);
1077        assert!(kept.item(&items[0]).unwrap().folders.is_empty());
1078        assert!(kept.items().iter().all(|i| !i.is_deleted));
1079        assert_eq!(kept.item(&items[2]).unwrap().folders, std::slice::from_ref(&c));
1080        assert_eq!(kept.metadata.quick_access.len(), 1, "the subfolder is unpinned");
1081
1082        // Trashing: items left in no folder go, the one still in C stays.
1083        let tmp = tempfile::tempdir().unwrap();
1084        let (mut lib, items) = with_items(tmp.path());
1085        let a = lib.create_folder(None, "A").unwrap();
1086        let a1 = lib.create_folder(Some(&a), "A1").unwrap();
1087        let b = lib.create_folder(None, "B").unwrap();
1088        let c = lib.create_folder(None, "C").unwrap();
1089        file_into(&mut lib, &items[0], &[&a1]);
1090        file_into(&mut lib, &items[1], &[&a, &b]);
1091        file_into(&mut lib, &items[2], &[&b, &c]);
1092        // A1 is inside A: naming both is fine.
1093        lib.delete_folders(&[a1.clone(), a.clone(), b.clone()], true).unwrap();
1094        let got = Library::open(lib.root()).unwrap();
1095        assert!(got.item(&items[0]).unwrap().is_deleted);
1096        assert!(got.item(&items[1]).unwrap().is_deleted);
1097        assert!(!got.item(&items[2]).unwrap().is_deleted);
1098        assert!(got.item(&items[0]).unwrap().extra.get("deletedTime").is_none(), "as Eagle, no deletedTime");
1099
1100        let before = lib.metadata.folders.clone();
1101        assert!(lib.delete_folders(&[c.clone(), "NOPE".into()], true).is_err());
1102        assert_eq!(lib.metadata.folders, before, "nothing changes on an unknown id");
1103    }
1104
1105    #[test]
1106    fn batch_rename_restyle_and_sort() {
1107        let tmp = tempfile::tempdir().unwrap();
1108        let mut lib = Library::create(tmp.path().join("R.library")).unwrap();
1109        let a = lib.create_folder(None, "A").unwrap();
1110        let b = lib.create_folder(Some(&a), "B").unwrap();
1111        let c = lib.create_folder(None, "C").unwrap();
1112        let stamp = lib.metadata.folders[0].modification_time;
1113        lib.set_folder_order(&c, Some(FolderOrder::By(crate::search::OrderBy::Name)), false).unwrap();
1114
1115        lib.rename_folders(&[(a.clone(), "Cafe\u{301} 1".into()), (b.clone(), "Two".into())]).unwrap();
1116        let got = Library::open(lib.root()).unwrap();
1117        assert_eq!(got.metadata.folders[0].name, "Caf\u{e9} 1", "NFC as Eagle saves");
1118        assert_eq!(got.metadata.folders[0].children[0].name, "Two");
1119        assert_eq!(got.metadata.folders[0].modification_time, stamp);
1120        assert!(lib.rename_folders(&[(c.clone(), "X".into()), ("NOPE".into(), "Y".into())]).is_err());
1121        assert_eq!(lib.metadata.folders[1].name, "C");
1122
1123        let all = [a.clone(), b.clone(), c.clone()];
1124        lib.restyle_folders(&all, None, Some(Some("red"))).unwrap();
1125        lib.restyle_folders(&all[..2], Some(Some("star")), None).unwrap();
1126        let got = Library::open(lib.root()).unwrap();
1127        let f = got.all_folders();
1128        assert_eq!((f[0].icon.as_deref(), f[0].icon_color.as_deref()), (Some("star"), Some("red")));
1129        assert_eq!((f[2].icon.as_deref(), f[2].icon_color.as_deref()), (None, Some("red")));
1130        assert!(lib.restyle_folders(&all, None, Some(Some("mauve"))).is_err());
1131        lib.restyle_folders(&all, Some(None), Some(None)).unwrap();
1132        let text = std::fs::read_to_string(lib.root().join("metadata.json")).unwrap();
1133        assert!(!text.contains("iconColor") && !text.contains("\"icon\""), "keys removed: {text}");
1134
1135        // A shared key keeps each folder's direction; Global Settings drops both keys.
1136        lib.set_folders_order(&all, Some(FolderOrder::By(crate::search::OrderBy::FileSize))).unwrap();
1137        let f = lib.all_folders();
1138        assert_eq!(f.iter().map(|f| f.sort_increase).collect::<Vec<_>>(), [Some(true), Some(true), Some(false)]);
1139        assert!(f.iter().all(|f| f.order_by.as_deref() == Some("FILESIZE")), "{:?}", f.iter().map(|f| &f.order_by).collect::<Vec<_>>());
1140        lib.set_folders_increase(&all, false).unwrap();
1141        assert!(lib.all_folders().iter().all(|f| f.sort_increase == Some(false)));
1142        lib.set_folders_order(&all, None).unwrap();
1143        let text = std::fs::read_to_string(lib.root().join("metadata.json")).unwrap();
1144        assert!(!text.contains("orderBy") && !text.contains("sortIncrease"));
1145    }
1146
1147    #[test]
1148    fn moving_several_folders() {
1149        let tmp = tempfile::tempdir().unwrap();
1150        let mut lib = Library::create(tmp.path().join("M.library")).unwrap();
1151        let a = lib.create_folder(None, "A").unwrap();
1152        let a1 = lib.create_folder(Some(&a), "A1").unwrap();
1153        let a2 = lib.create_folder(Some(&a), "A2").unwrap();
1154        let b = lib.create_folder(None, "B").unwrap();
1155        let c = lib.create_folder(None, "C").unwrap();
1156        let d = lib.create_folder(None, "D").unwrap();
1157        let tree = |l: &Library| -> Vec<String> {
1158            fn go(list: &[Folder], depth: usize, out: &mut Vec<String>) {
1159                for f in list {
1160                    out.push(format!("{}{}", " ".repeat(depth), f.name));
1161                    go(&f.children, depth + 1, out);
1162                }
1163            }
1164            let mut out = Vec::new();
1165            go(&l.metadata.folders, 0, &mut out);
1166            out
1167        };
1168
1169        // One undo step puts everything back.
1170        let start = tree(&lib);
1171        lib.begin_transaction("move");
1172        lib.move_folders(&[c.clone(), d.clone()], &a, FolderPlace::Into).unwrap();
1173        let t = lib.end_transaction().unwrap();
1174        lib.revert(t).unwrap();
1175        assert_eq!(tree(&Library::open(lib.root()).unwrap()), start);
1176
1177        // Picked in any order, they keep their tree order: A2 then C.
1178        lib.move_folders(&[c.clone(), a2.clone()], &b, FolderPlace::Above).unwrap();
1179        assert_eq!(tree(&lib), ["A", " A1", "A2", "C", "B", "D"]);
1180        lib.move_folders(&[a2.clone(), d.clone()], &a1, FolderPlace::Below).unwrap();
1181        assert_eq!(tree(&lib), ["A", " A1", " A2", " D", "C", "B"]);
1182        // Into: after the last child. A moves with its children; A1, whose
1183        // parent moves too, stays inside it.
1184        lib.move_folders(&[a1.clone(), a.clone()], &b, FolderPlace::Into).unwrap();
1185        assert_eq!(tree(&Library::open(lib.root()).unwrap()), ["C", "B", " A", "  A1", "  A2", "  D"]);
1186
1187        let before = tree(&lib);
1188        assert!(lib.move_folders(std::slice::from_ref(&b), &a2, FolderPlace::Into).is_err(), "into its own descendant");
1189        assert!(lib.move_folders(&[b.clone(), c.clone()], &c, FolderPlace::Above).is_err(), "onto a moved folder");
1190        assert!(lib.move_folders(std::slice::from_ref(&c), "NOPE", FolderPlace::Above).is_err());
1191        assert_eq!(tree(&lib), before);
1192
1193        // To the top level: nested picks stay with their parent, after the rest.
1194        lib.move_folders_to_top(&[a1.clone(), a.clone(), c.clone()]).unwrap();
1195        assert_eq!(tree(&Library::open(lib.root()).unwrap()), ["B", "C", "A", " A1", " A2", " D"]);
1196        let before = tree(&lib);
1197        assert!(lib.move_folders_to_top(&["nope".to_string()]).is_err());
1198        assert_eq!(tree(&lib), before);
1199    }
1200
1201    #[test]
1202    fn duplicating_a_folder() {
1203        let tmp = tempfile::tempdir().unwrap();
1204        let (mut lib, items) = with_items(tmp.path());
1205        let a = lib.create_folder(None, "A").unwrap();
1206        let a1 = lib.create_folder(Some(&a), "A1").unwrap();
1207        lib.create_folder(Some(&a1), "A1x").unwrap();
1208        let b = lib.create_folder(None, "B").unwrap();
1209        lib.set_folder_icon(&a1, Some("star"), Some("blue")).unwrap();
1210        lib.set_folder_password(&a1, Some(("pw", "hint"))).unwrap();
1211        lib.set_folder_description(&a1, "about").unwrap();
1212        lib.set_folder_cover(&a1, Some(&items[0])).unwrap();
1213        lib.set_folder_order(&a1, Some(FolderOrder::Random), false).unwrap();
1214        file_into(&mut lib, &items[0], &[&a1]);
1215        let original = lib.metadata.folders[0].children[0].clone();
1216
1217        let copy = lib.duplicate_folder(&a1).unwrap();
1218        let got = Library::open(lib.root()).unwrap();
1219        let kids = &got.metadata.folders[0].children;
1220        assert_eq!(names(kids), ["A1", "A1"], "right after the original, same name");
1221        assert_eq!(kids[0], original, "the original is untouched");
1222        let dup = &kids[1];
1223        assert_eq!(dup.id, copy);
1224        assert_ne!(dup.children[0].id, original.children[0].id);
1225        assert_eq!(dup.children[0].name, "A1x");
1226        assert_eq!((dup.icon.as_deref(), dup.icon_color.as_deref(), dup.description.as_str()), (Some("star"), Some("blue"), "about"));
1227        assert_eq!((dup.cover_id.as_deref(), dup.order_by.as_deref(), dup.sort_increase), (Some(items[0].as_str()), Some("RANDOM"), Some(false)));
1228        assert!(!dup.is_locked() && dup.password_tips.is_empty());
1229        assert!(dup.modification_time >= original.modification_time);
1230        assert_eq!(got.item(&items[0]).unwrap().folders, std::slice::from_ref(&a1), "items are not copied");
1231
1232        // A top-level folder lands after itself, before the next one.
1233        lib.duplicate_folder(&b).unwrap();
1234        assert_eq!(names(&lib.metadata.folders), ["A", "B", "B"]);
1235        assert!(lib.duplicate_folder("NOPE").is_err());
1236    }
1237
1238    #[test]
1239    fn passwords_encode_like_btoa() {
1240        assert_eq!(encode_password("123").as_deref(), Some("MTIz"));
1241        // btoa takes each UTF-16 unit below 256 as a byte, not UTF-8.
1242        assert_eq!(encode_password("é").as_deref(), Some("6Q=="));
1243        assert_eq!(encode_password("密码"), None);
1244    }
1245}