1use 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
22pub const PINNED_KEY: &str = "pinned";
24pub const MARKED_KEY: &str = "marked";
26pub const MAX_EXTENSION: usize = 10;
28
29pub 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
36pub 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
42pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ExtensionError {
57 Empty,
58 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
73pub 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
88fn 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
94fn 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
101fn 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 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
109pub(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 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 write_json(&to.join("metadata.json"), ©)?;
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 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 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 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 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 Err(_) => {
271 crate::import::restat(&mut item, &new)?;
272 self.update_item(item)
273 }
274 }
275 }
276}
277
278fn 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
287fn 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}