Skip to main content

falcon_library/
item_ops.rs

1//! Item commands that work on an item's files or on per-item keys Eagle
2//! writes for them: Duplicate, Replace with File, changing the extension,
3//! Pin to Top inside a folder and marking a palette colour as the item's
4//! primary colour.
5//!
6//! On disk these follow Eagle: a duplicate is a copy of the whole
7//! `<ID>.info` folder under a new id whose `metadata.json` differs only in
8//! `id`; a pin is `pinned: {<folder id>: <ms>}` on the item; a primary
9//! colour is `marked: true` on one palette entry (Eagle writes `false`
10//! when it is unmarked again). Originals are never left half written: new
11//! bytes go to a temporary file in the `.info` folder first and the old
12//! original is kept as `<file>.bk` until the new one is in place.
13
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use crate::error::{Error, Result};
18use crate::ids;
19use crate::library::{Library, write_json};
20use crate::model::Item;
21
22/// The item key holding the folders it is pinned in.
23pub const PINNED_KEY: &str = "pinned";
24/// The palette key marking an item's primary colour.
25pub const MARKED_KEY: &str = "marked";
26/// Longest extension the Change Extension dialog accepts.
27pub const MAX_EXTENSION: usize = 10;
28
29/// When the item was pinned to the top of `folder` (ms), if it is. As in
30/// Eagle, a zero stamp counts as not pinned.
31pub fn pinned_at(item: &Item, folder: &str) -> Option<f64> {
32    let t = item.extra.get(PINNED_KEY)?.get(folder)?.as_f64()?;
33    (t != 0.0).then_some(t)
34}
35
36/// Whether every item is pinned in `folder` (Eagle then offers Unpin
37/// instead of Pin; it looks at the first 100 of the selection).
38pub fn all_pinned<'a>(items: impl IntoIterator<Item = &'a Item>, folder: &str) -> bool {
39    items.into_iter().take(100).all(|i| pinned_at(i, folder).is_some())
40}
41
42/// Moves the items pinned in `folder` to the front, the most recently
43/// pinned first; the rest keep their order. Eagle does this inside a folder
44/// unless the folder is sorted at random.
45pub fn pinned_first(order: &mut [usize], items: &[Item], folder: &str) {
46    order.sort_by(|&a, &b| match (pinned_at(&items[a], folder), pinned_at(&items[b], folder)) {
47        (Some(x), Some(y)) => y.total_cmp(&x),
48        (Some(_), None) => std::cmp::Ordering::Less,
49        (None, Some(_)) => std::cmp::Ordering::Greater,
50        (None, None) => std::cmp::Ordering::Equal,
51    });
52}
53
54/// Why an extension typed into the Change Extension dialog is refused.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ExtensionError {
57    Empty,
58    /// Only ASCII letters and digits are allowed.
59    BadCharacter,
60    TooLong,
61}
62
63impl std::fmt::Display for ExtensionError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(match self {
66            ExtensionError::Empty => "Type an extension",
67            ExtensionError::BadCharacter => "Use only letters and digits",
68            ExtensionError::TooLong => "At most 10 characters",
69        })
70    }
71}
72
73/// Checks an extension as Eagle's dialog does (letters and digits, at
74/// most [`MAX_EXTENSION`]) and returns it in lower case, as stored.
75pub fn check_extension(text: &str) -> std::result::Result<String, ExtensionError> {
76    let t = text.trim();
77    if t.is_empty() {
78        Err(ExtensionError::Empty)
79    } else if !t.chars().all(|c| c.is_ascii_alphanumeric()) {
80        Err(ExtensionError::BadCharacter)
81    } else if t.len() > MAX_EXTENSION {
82        Err(ExtensionError::TooLong)
83    } else {
84        Ok(t.to_ascii_lowercase())
85    }
86}
87
88/// The original's file name with another extension.
89fn file_name(name: &str, ext: &str) -> String {
90    let file = if ext.is_empty() { name.to_string() } else { format!("{name}.{ext}") };
91    crate::disk_name::on_disk(&file).into_owned()
92}
93
94/// `path` with `suffix` added to its file name.
95fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
96    let mut s = path.as_os_str().to_owned();
97    s.push(suffix);
98    PathBuf::from(s)
99}
100
101/// Copies `src` to `dest` and flushes it to disk.
102fn copy_synced(src: &Path, dest: &Path) -> Result<()> {
103    fs::copy(src, dest).map_err(|e| Error::io(dest, e))?;
104    crate::disk_name::make_writable(dest).map_err(|e| Error::io(dest, e))?;
105    // Windows only flushes a file opened for writing.
106    fs::OpenOptions::new().read(true).write(cfg!(windows)).open(dest).and_then(|f| f.sync_all()).map_err(|e| Error::io(dest, e))
107}
108
109/// Puts `staged` (a finished file in the same folder) where `old` was,
110/// under the name `new`: `old` becomes `old.bk`, `staged` is renamed to
111/// `new`, then the backup goes. If the rename fails the original comes
112/// back and `staged` is removed.
113pub(crate) fn swap_in(staged: &Path, old: &Path, new: &Path) -> Result<()> {
114    let bk = with_suffix(old, ".bk");
115    if let Err(e) = fs::rename(old, &bk) {
116        let _ = fs::remove_file(staged);
117        return Err(Error::io(old, e));
118    }
119    if let Err(e) = fs::rename(staged, new) {
120        let _ = fs::rename(&bk, old);
121        let _ = fs::remove_file(staged);
122        return Err(Error::io(new, e));
123    }
124    let _ = fs::remove_file(&bk);
125    Ok(())
126}
127
128impl Library {
129    /// Eagle's Duplicate (⌘D): copies the item's whole `.info` folder to a
130    /// new id and writes its `metadata.json` with only the `id` changed, so
131    /// the copy keeps the name, tags, folders, dates and everything else.
132    /// Returns the new id.
133    pub fn duplicate_item(&mut self, id: &str) -> Result<String> {
134        let item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
135        let from = item.info_dir(self.root());
136        let mut new_id = ids::new_id();
137        while self.root().join("images").join(format!("{new_id}.info")).exists() || self.item(&new_id).is_some() {
138            new_id = ids::new_id();
139        }
140        let mut copy = item;
141        copy.id = new_id.clone();
142        let to = copy.info_dir(self.root());
143        let made = (|| -> Result<()> {
144            copy_dir(&from, &to)?;
145            // Written at once even in batched mode, so the folder never
146            // holds the original's metadata under the new name.
147            write_json(&to.join("metadata.json"), &copy)?;
148            self.register_item(copy)
149        })();
150        match made {
151            Ok(()) => Ok(new_id),
152            Err(e) => {
153                let _ = fs::remove_dir_all(&to);
154                Err(e)
155            }
156        }
157    }
158
159    /// Pins the items to the top of `folders` (Eagle's Pin to Top), or
160    /// unpins them. Only the folders each item is in are touched. A pin is
161    /// stamped `now - n` for the `n`th item, so they keep their order at
162    /// the top; unpinning the last folder removes the `pinned` key.
163    /// Returns how many items changed.
164    pub fn set_pinned(&mut self, ids: &[String], folders: &[String], pin: bool, now: u64) -> Result<usize> {
165        let mut changed = 0;
166        for (n, id) in ids.iter().enumerate() {
167            let Some(mut item) = self.item(id).cloned() else { continue };
168            let targets: Vec<&String> = folders.iter().filter(|f| item.folders.contains(f)).collect();
169            if targets.is_empty() {
170                continue;
171            }
172            let mut map = match item.extra.get(PINNED_KEY) {
173                Some(serde_json::Value::Object(m)) => m.clone(),
174                _ => serde_json::Map::new(),
175            };
176            let before = map.clone();
177            for f in targets {
178                if pin {
179                    map.insert(f.clone(), now.saturating_sub(n as u64).into());
180                } else {
181                    map.remove(f.as_str());
182                }
183            }
184            if map == before {
185                continue;
186            }
187            if map.is_empty() {
188                item.extra.shift_remove(PINNED_KEY);
189            } else {
190                item.extra.insert(PINNED_KEY.into(), serde_json::Value::Object(map));
191            }
192            self.update_item(item)?;
193            changed += 1;
194        }
195        Ok(changed)
196    }
197
198    /// Marks or unmarks palette entry `index` as the item's primary colour
199    /// (Eagle's "Set as Primary Color"). Like Eagle, unmarking writes
200    /// `marked: false` rather than dropping the key.
201    pub fn set_palette_marked(&mut self, id: &str, index: usize, marked: bool) -> Result<()> {
202        let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
203        let Some(p) = item.palettes.get_mut(index) else { return Err(self.invalid("no such palette colour")) };
204        p.extra.insert(MARKED_KEY.into(), marked.into());
205        self.update_item(item)
206    }
207
208    /// Eagle's Change Extension: renames the original to the new extension
209    /// (checked with [`check_extension`]) and stores it. The file's bytes,
210    /// the thumbnail and everything else stay. Refused when a file of that
211    /// name is already in the `.info` folder.
212    pub fn set_extension(&mut self, id: &str, ext: &str) -> Result<()> {
213        let ext = check_extension(ext).map_err(|e| self.invalid(e.to_string()))?;
214        let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
215        if ext == item.ext {
216            return Ok(());
217        }
218        let old = item.original_path(self.root());
219        let new = item.info_dir(self.root()).join(file_name(&item.name, &ext));
220        if new.exists() && !same_file(&old, &new) {
221            return Err(self.invalid(format!("{} already exists", new.display())));
222        }
223        fs::rename(&old, &new).map_err(|e| Error::io(&old, e))?;
224        item.ext = ext;
225        if let Err(e) = self.update_item(item) {
226            let _ = fs::rename(&new, &old);
227            return Err(e);
228        }
229        if let Some(j) = &mut self.journal {
230            j.irreversible = true;
231        }
232        Ok(())
233    }
234
235    /// Eagle's Replace with File: the item keeps its id, name and
236    /// everything it was given, but its original becomes a copy of `src`
237    /// with `src`'s extension (lower case, `unknown` when it has none).
238    /// Size, dates and dimensions are read again and the thumbnail and
239    /// palette redrawn, except that a custom thumbnail is kept.
240    pub fn replace_original(&mut self, id: &str, src: &Path) -> Result<()> {
241        let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
242        if !src.is_file() {
243            return Err(Error::Import { path: src.to_path_buf(), reason: "not a file".into() });
244        }
245        let ext = src.extension().map(|e| e.to_string_lossy().to_lowercase()).filter(|e| !e.is_empty()).unwrap_or_else(|| "unknown".into());
246        let dir = item.info_dir(self.root());
247        let old = item.original_path(self.root());
248        let new = dir.join(file_name(&item.name, &ext));
249        if new != old && new.exists() {
250            return Err(self.invalid(format!("{} already exists", new.display())));
251        }
252        let staged = dir.join(format!(".{}.falcon-replace", file_name(&item.name, &ext)));
253        if let Err(e) = copy_synced(src, &staged) {
254            let _ = fs::remove_file(&staged);
255            return Err(e);
256        }
257        swap_in(&staged, &old, &new)?;
258        if let Some(j) = &mut self.journal {
259            j.irreversible = true;
260        }
261        item.ext = ext;
262        if crate::thumbnail::has_custom_thumbnail(&item) {
263            crate::import::restat(&mut item, &new)?;
264            return self.update_item(item);
265        }
266        match self.redraw_item(item.clone()) {
267            Ok(()) => Ok(()),
268            // A format falcon can't draw still gets its new extension and
269            // file facts; the old thumbnail stays, as in Eagle.
270            Err(_) => {
271                crate::import::restat(&mut item, &new)?;
272                self.update_item(item)
273            }
274        }
275    }
276}
277
278/// Whether two paths name the same file (a case-only rename on a
279/// case-insensitive disk).
280fn same_file(a: &Path, b: &Path) -> bool {
281    match (fs::canonicalize(a), fs::canonicalize(b)) {
282        (Ok(x), Ok(y)) => x == y,
283        _ => false,
284    }
285}
286
287/// Copies a folder's files and subfolders, except `metadata.json` at its
288/// top, keeping file times.
289fn copy_dir(from: &Path, to: &Path) -> Result<()> {
290    fn go(from: &Path, to: &Path, top: bool) -> Result<()> {
291        fs::create_dir_all(to).map_err(|e| Error::io(to, e))?;
292        for entry in fs::read_dir(from).map_err(|e| Error::io(from, e))? {
293            let entry = entry.map_err(|e| Error::io(from, e))?;
294            let name = entry.file_name();
295            if top && name == "metadata.json" {
296                continue;
297            }
298            let (src, dest) = (entry.path(), to.join(&name));
299            if entry.file_type().map_err(|e| Error::io(&src, e))?.is_dir() {
300                go(&src, &dest, false)?;
301            } else {
302                fs::copy(&src, &dest).map_err(|e| Error::io(&dest, e))?;
303                if let Ok(m) = fs::metadata(&src) {
304                    let _ = filetime::set_file_times(&dest, filetime::FileTime::from_last_access_time(&m), filetime::FileTime::from_last_modification_time(&m));
305                }
306            }
307        }
308        Ok(())
309    }
310    if to.exists() {
311        return Err(Error::Import { path: to.to_path_buf(), reason: "already exists".into() });
312    }
313    go(from, to, true)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn item(id: &str, pinned: Option<serde_json::Value>) -> Item {
321        let mut v = serde_json::json!({"id": id, "name": id, "size": 1, "btime": 1, "mtime": 1, "ext": "png", "tags": [], "folders": ["F"], "isDeleted": false, "url": "", "annotation": "", "modificationTime": 1, "height": 1, "width": 1, "lastModified": 1, "palettes": []});
322        if let Some(p) = pinned {
323            v["pinned"] = p;
324        }
325        serde_json::from_value(v).unwrap()
326    }
327
328    #[test]
329    fn pinned_items_come_first_newest_pin_first() {
330        let items = vec![
331            item("a", None),
332            item("b", Some(serde_json::json!({"F": 100}))),
333            item("c", None),
334            item("d", Some(serde_json::json!({"F": 300, "G": 1}))),
335            item("e", Some(serde_json::json!({"G": 500}))),
336            item("f", Some(serde_json::json!({"F": 0}))),
337        ];
338        let mut order: Vec<usize> = (0..items.len()).collect();
339        pinned_first(&mut order, &items, "F");
340        let ids: Vec<&str> = order.iter().map(|&i| items[i].id.as_str()).collect();
341        assert_eq!(ids, ["d", "b", "a", "c", "e", "f"]);
342        assert!(all_pinned([&items[1], &items[3]], "F"));
343        assert!(!all_pinned([&items[1], &items[4]], "F"));
344        assert_eq!(pinned_at(&items[5], "F"), None, "a zero stamp is not a pin");
345    }
346
347    #[test]
348    fn extensions_are_checked_like_eagle() {
349        assert_eq!(check_extension("JPG"), Ok("jpg".into()));
350        assert_eq!(check_extension(" mp4 "), Ok("mp4".into()));
351        assert_eq!(check_extension(""), Err(ExtensionError::Empty));
352        assert_eq!(check_extension("tar.gz"), Err(ExtensionError::BadCharacter));
353        assert_eq!(check_extension("é"), Err(ExtensionError::BadCharacter));
354        assert_eq!(check_extension("abcdefghijk"), Err(ExtensionError::TooLong));
355        assert_eq!(check_extension("abcdefghij"), Ok("abcdefghij".into()));
356    }
357}