1use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use filetime::FileTime;
18
19use crate::error::{Error, Result};
20use crate::library::Library;
21use crate::model::{Folder, Item};
22use crate::smart::SmartFolder;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Collision {
27 Replace,
29 KeepBoth,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Step {
36 pub id: String,
37 pub from: PathBuf,
38 pub to: PathBuf,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Plan {
44 pub dirs: Vec<PathBuf>,
46 pub steps: Vec<Step>,
47 pub top: PathBuf,
50 pub overwrite: bool,
52}
53
54impl Plan {
55 pub fn clashes(&self) -> usize {
58 self.steps.iter().filter(|s| s.to.exists()).count()
59 }
60}
61
62pub fn file_name(item: &Item) -> String {
65 let name = item.name.replace(['/', '\0'], " ");
66 crate::disk_name::on_disk(&if item.ext.is_empty() { name } else { format!("{name}.{}", item.ext) }).into_owned()
67}
68
69fn stem_ext(file: &str) -> (&str, Option<&str>) {
71 match file.rfind('.') {
72 Some(i) if i > 0 => (&file[..i], Some(&file[i + 1..])),
73 _ => (file, None),
74 }
75}
76
77pub fn name_files(files: &[String], existing: &HashSet<String>, how: Collision) -> Vec<String> {
84 let keep = how == Collision::KeepBoth;
85 let mut count: HashMap<String, usize> = HashMap::new();
86 for f in files {
87 *count.entry(f.to_lowercase()).or_default() += 1;
88 }
89 let mut taken: HashSet<String> = if keep { existing.clone() } else { HashSet::new() };
90 let plain: HashSet<String> = count.iter().filter(|(k, n)| **n == 1 && !taken.contains(*k)).map(|(k, _)| k.clone()).collect();
93 taken.extend(plain.iter().cloned());
94 let mut next: HashMap<String, usize> = HashMap::new();
95 files
96 .iter()
97 .map(|f| {
98 let lower = f.to_lowercase();
99 if plain.contains(&lower) {
100 return f.clone();
101 }
102 let had = usize::from(keep && existing.contains(&lower));
103 let width = (count[&lower] + had).to_string().len();
104 let n = next.entry(lower).or_insert(had);
105 let (stem, ext) = stem_ext(f);
106 loop {
107 *n += 1;
108 let candidate = match ext {
109 Some(e) => format!("{stem}-{:0width$}.{e}", *n),
110 None => format!("{stem}-{:0width$}", *n),
111 };
112 if taken.insert(candidate.to_lowercase()) {
113 return candidate;
114 }
115 }
116 })
117 .collect()
118}
119
120fn listing(dir: &Path) -> HashSet<String> {
122 fs::read_dir(dir).map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_lowercase()).collect()).unwrap_or_default()
123}
124
125pub fn plan_items(lib: &Library, ids: &[String], dest: &Path, how: Collision) -> Plan {
128 let mut seen = HashSet::new();
129 let items: Vec<&Item> = ids.iter().filter(|id| seen.insert(id.as_str())).filter_map(|id| lib.item(id)).collect();
130 let mut plan = Plan { dirs: vec![dest.to_path_buf()], steps: Vec::new(), top: dest.to_path_buf(), overwrite: how == Collision::Replace };
131 add_steps(&mut plan, lib.root(), &items, dest, &listing(dest), how);
132 plan
133}
134
135pub fn folder_dir_name(name: &str) -> String {
140 let spaced: String = name.chars().map(|c| if matches!(c, ':' | '/' | '\\' | '\0') || c.is_whitespace() { ' ' } else { c }).collect();
141 let mut out = spaced.split(' ').filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
142 while out.ends_with('.') {
143 out.pop();
144 }
145 let out = out.trim_end().to_string();
146 if out.is_empty() { "Untitled".into() } else { out }
147}
148
149pub fn plan_folder(lib: &Library, id: &str, dest: &Path, how: Collision, hidden: &HashSet<String>) -> Option<Plan> {
156 let folder = lib.all_folders().into_iter().find(|f| f.id == id)?;
157 let top = dest.join(folder_dir_name(&folder.name));
158 let mut dirs: Vec<(PathBuf, Vec<&Item>)> = Vec::new();
159 let mut by_folder: HashMap<&str, Vec<&Item>> = HashMap::new();
160 for item in lib.items().iter().filter(|i| !i.is_deleted && !i.folders.iter().any(|f| hidden.contains(f))) {
161 for f in &item.folders {
162 by_folder.entry(f.as_str()).or_default().push(item);
163 }
164 }
165 fn walk<'a>(f: &'a Folder, dir: PathBuf, by_folder: &HashMap<&str, Vec<&'a Item>>, hidden: &HashSet<String>, dirs: &mut Vec<(PathBuf, Vec<&'a Item>)>) {
166 let items = by_folder.get(f.id.as_str()).cloned().unwrap_or_default();
167 match dirs.iter_mut().find(|(d, _)| *d == dir) {
168 Some((_, list)) => list.extend(items),
169 None => dirs.push((dir.clone(), items)),
170 }
171 for c in f.children.iter().filter(|c| !hidden.contains(&c.id)) {
172 walk(c, dir.join(folder_dir_name(&c.name)), by_folder, hidden, dirs);
173 }
174 }
175 walk(folder, top.clone(), &by_folder, hidden, &mut dirs);
176 let mut plan = Plan { dirs: Vec::new(), steps: Vec::new(), top, overwrite: how == Collision::Replace };
177 for (dir, mut items) in dirs {
178 let mut seen = HashSet::new();
179 items.retain(|i| seen.insert(i.id.as_str()));
180 add_steps(&mut plan, lib.root(), &items, &dir, &listing(&dir), how);
181 plan.dirs.push(dir);
182 }
183 Some(plan)
184}
185
186pub fn plan_smart(lib: &Library, id: &str, dest: &Path, how: Collision, hidden: &HashSet<String>) -> Option<Plan> {
191 let name = find_smart(&lib.smart_folders(), id)?.name.clone();
192 let top = dest.join(folder_dir_name(&name));
193 let items: Vec<&Item> = lib.smart_folder_items(id).into_iter().filter(|i| !i.folders.iter().any(|f| hidden.contains(f))).collect();
194 let mut plan = Plan { dirs: vec![top.clone()], steps: Vec::new(), top: top.clone(), overwrite: how == Collision::Replace };
195 add_steps(&mut plan, lib.root(), &items, &top, &listing(&top), how);
196 Some(plan)
197}
198
199fn find_smart<'a>(list: &'a [SmartFolder], id: &str) -> Option<&'a SmartFolder> {
200 list.iter().find_map(|s| if s.id == id { Some(s) } else { find_smart(&s.children, id) })
201}
202
203fn add_steps(plan: &mut Plan, root: &Path, items: &[&Item], dir: &Path, existing: &HashSet<String>, how: Collision) {
204 add_named_steps(plan, root, items, items.iter().map(|i| file_name(i)).collect(), dir, existing, how);
205}
206
207fn add_named_steps(plan: &mut Plan, root: &Path, items: &[&Item], names: Vec<String>, dir: &Path, existing: &HashSet<String>, how: Collision) {
208 let names = name_files(&names, existing, how);
209 for (item, name) in items.iter().zip(names) {
210 plan.steps.push(Step { id: item.id.clone(), from: item.original_path(root), to: dir.join(name) });
211 }
212}
213
214pub fn copy_file(from: &Path, to: &Path, overwrite: bool) -> Result<()> {
218 let dir = to.parent().unwrap_or(Path::new("."));
219 let tmp = tempfile::Builder::new().prefix(".falcon-export").tempfile_in(dir).map_err(|e| Error::io(dir, e))?;
220 fs::copy(from, tmp.path()).map_err(|e| Error::io(from, e))?;
221 if let Ok(meta) = fs::metadata(from) {
222 let _ = filetime::set_file_mtime(tmp.path(), FileTime::from_last_modification_time(&meta));
223 }
224 if overwrite {
225 tmp.persist(to).map_err(|e| Error::io(to, e.error))?;
226 } else {
227 tmp.persist_noclobber(to).map_err(|e| Error::io(to, e.error))?;
228 }
229 Ok(())
230}
231
232pub fn run(plan: &Plan, cancel: &AtomicBool, mut on_step: impl FnMut(&Step, bool)) -> Result<Vec<PathBuf>> {
237 for d in &plan.dirs {
238 fs::create_dir_all(d).map_err(|e| Error::io(d, e))?;
239 }
240 let mut written = Vec::new();
241 for step in &plan.steps {
242 if cancel.load(Ordering::Relaxed) {
243 break;
244 }
245 let ok = copy_file(&step.from, &step.to, plan.overwrite).is_ok();
246 if ok {
247 written.push(step.to.clone());
248 }
249 on_step(step, ok);
250 }
251 Ok(written)
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum Format {
260 Jpeg,
261 Png,
262 Webp,
263}
264
265impl Format {
266 pub const ALL: [Format; 3] = [Format::Jpeg, Format::Png, Format::Webp];
267
268 pub fn ext(self) -> &'static str {
269 match self {
270 Format::Jpeg => "jpg",
271 Format::Png => "png",
272 Format::Webp => "webp",
273 }
274 }
275
276 pub fn label(self) -> &'static str {
277 match self {
278 Format::Jpeg => "JPG",
279 Format::Png => "PNG",
280 Format::Webp => "WebP",
281 }
282 }
283
284 pub fn lossy(self) -> bool {
286 self != Format::Png
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub struct Convert {
293 pub format: Format,
294 pub quality: u8,
296 pub longest: Option<u32>,
298}
299
300pub fn convertible(item: &Item) -> bool {
303 matches!(item.ext.to_lowercase().as_str(), "png" | "jpg" | "jpeg" | "jfif" | "jpe" | "insp" | "webp" | "gif" | "bmp" | "tif" | "tiff" | "ico" | "svg")
304}
305
306pub fn plan_converted(lib: &Library, ids: &[String], dest: &Path, format: Format, how: Collision) -> Plan {
310 let mut seen = HashSet::new();
311 let items: Vec<&Item> = ids.iter().filter(|id| seen.insert(id.as_str())).filter_map(|id| lib.item(id)).filter(|i| convertible(i)).collect();
312 let names = items.iter().map(|i| crate::disk_name::on_disk(&format!("{}.{}", i.name.replace(['/', '\0'], " "), format.ext())).into_owned()).collect();
313 let mut plan = Plan { dirs: vec![dest.to_path_buf()], steps: Vec::new(), top: dest.to_path_buf(), overwrite: how == Collision::Replace };
314 add_named_steps(&mut plan, lib.root(), &items, names, dest, &listing(dest), how);
315 plan
316}
317
318pub fn convert_bytes(from: &Path, convert: &Convert) -> std::result::Result<Vec<u8>, String> {
321 use image::{DynamicImage, ImageEncoder};
322 let is_svg = from.extension().is_some_and(|e| e.eq_ignore_ascii_case("svg"));
323 let mut img = if is_svg {
324 crate::thumbnail::render_svg(&fs::read(from).map_err(|e| e.to_string())?, None).map_err(|e| e.0)?
325 } else {
326 crate::thumbnail::decode(from).map_err(|e| e.0)?
327 };
328 if let Some(max) = convert.longest
329 && img.width().max(img.height()) > max
330 {
331 img = img.resize(max, max, image::imageops::FilterType::Lanczos3);
332 }
333 let quality = convert.quality.clamp(1, 100);
334 let mut out = Vec::new();
335 match convert.format {
336 Format::Jpeg => {
337 let rgba = img.to_rgba8();
339 let rgb = image::RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| {
340 let [r, g, b, a] = rgba.get_pixel(x, y).0;
341 let mix = |c: u8| ((u16::from(c) * u16::from(a) + 255 * (255 - u16::from(a))) / 255) as u8;
342 image::Rgb([mix(r), mix(g), mix(b)])
343 });
344 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&rgb).map_err(|e| e.to_string())?;
345 }
346 Format::Png => {
347 let img = if img.color().has_alpha() { DynamicImage::ImageRgba8(img.to_rgba8()) } else { DynamicImage::ImageRgb8(img.to_rgb8()) };
348 image::codecs::png::PngEncoder::new(&mut out).write_image(img.as_bytes(), img.width(), img.height(), img.color().into()).map_err(|e| e.to_string())?;
349 }
350 Format::Webp => {
351 let rgba = img.to_rgba8();
352 let enc = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height());
353 let mem = if quality >= 100 { enc.encode_lossless() } else { enc.encode(f32::from(quality)) };
354 out = mem.to_vec();
355 }
356 }
357 Ok(out)
358}
359
360pub fn run_converted(plan: &Plan, convert: &Convert, cancel: &AtomicBool, mut on_step: impl FnMut(&Step, bool)) -> Result<Vec<PathBuf>> {
364 for d in &plan.dirs {
365 fs::create_dir_all(d).map_err(|e| Error::io(d, e))?;
366 }
367 let mut written = Vec::new();
368 for step in &plan.steps {
369 if cancel.load(Ordering::Relaxed) {
370 break;
371 }
372 let ok = convert_bytes(&step.from, convert).ok().is_some_and(|bytes| write_new(&step.to, &bytes, plan.overwrite).is_ok());
373 if ok {
374 written.push(step.to.clone());
375 }
376 on_step(step, ok);
377 }
378 Ok(written)
379}
380
381fn write_new(to: &Path, bytes: &[u8], overwrite: bool) -> Result<()> {
383 let dir = to.parent().unwrap_or(Path::new("."));
384 let mut tmp = tempfile::Builder::new().prefix(".falcon-export").tempfile_in(dir).map_err(|e| Error::io(dir, e))?;
385 std::io::Write::write_all(&mut tmp, bytes).map_err(|e| Error::io(to, e))?;
386 if overwrite {
387 tmp.persist(to).map_err(|e| Error::io(to, e.error))?;
388 } else {
389 tmp.persist_noclobber(to).map_err(|e| Error::io(to, e.error))?;
390 }
391 Ok(())
392}
393
394pub const CSV_HEADER: [&str; 16] = [
402 "Item ID",
403 "Name",
404 "File Type",
405 "Width (px)",
406 "Height (px)",
407 "Duration (s)",
408 "Source URL",
409 "Notes",
410 "Comments",
411 "Tags",
412 "Folders",
413 "Size (bytes)",
414 "Rating",
415 "Date Imported (UTC)",
416 "Date Modified (UTC)",
417 "Original Path",
418];
419
420pub fn csv_field(s: &str) -> String {
423 if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
424}
425
426fn csv_time(ms: u64) -> String {
429 if ms == 0 {
430 return String::new();
431 }
432 chrono::DateTime::from_timestamp_millis(ms as i64).map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_default()
433}
434
435fn csv_number(v: Option<&serde_json::Value>) -> String {
437 match v {
438 Some(serde_json::Value::Number(n)) if n.as_f64().is_some_and(|f| f != 0.0) => n.to_string(),
439 _ => String::new(),
440 }
441}
442
443pub fn csv_row(lib: &Library, item: &Item, folder_names: &HashMap<&str, &str>) -> Vec<String> {
445 let nonzero = |n: u64| if n == 0 { String::new() } else { n.to_string() };
446 let comments: Vec<String> = item.comments().into_iter().map(|c| c.annotation).collect();
447 let folders: Vec<&str> = item.folders.iter().map(|f| folder_names.get(f.as_str()).copied().unwrap_or(f.as_str())).collect();
448 vec![
449 item.id.clone(),
450 item.name.clone(),
451 item.ext.clone(),
452 nonzero(item.width),
453 nonzero(item.height),
454 csv_number(item.extra.get("duration")),
455 item.url.clone(),
456 item.annotation.clone(),
457 comments.join("\n"),
458 item.tags.join(", "),
459 folders.join(", "),
460 nonzero(item.size),
461 csv_number(item.extra.get("star")),
462 csv_time(item.modification_time),
463 csv_time(item.last_modified),
464 item.original_path(lib.root()).display().to_string(),
465 ]
466}
467
468pub fn csv(lib: &Library, ids: &[String]) -> String {
472 let all = lib.all_folders();
473 let names: HashMap<&str, &str> = all.iter().map(|f| (f.id.as_str(), f.name.as_str())).collect();
474 let mut lines = vec![CSV_HEADER.iter().map(|h| csv_field(h)).collect::<Vec<_>>().join(",")];
475 for item in ids.iter().filter_map(|id| lib.item(id)) {
476 lines.push(csv_row(lib, item, &names).iter().map(|f| csv_field(f)).collect::<Vec<_>>().join(","));
477 }
478 format!("\u{feff}{}", lines.join("\n"))
479}
480
481pub fn write_csv(lib: &Library, ids: &[String], dest: &Path) -> Result<usize> {
484 let text = csv(lib, ids);
485 let dir = dest.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
486 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| Error::io(dir, e))?;
487 std::io::Write::write_all(&mut tmp, text.as_bytes()).map_err(|e| Error::io(dest, e))?;
488 tmp.persist(dest).map_err(|e| Error::io(dest, e.error))?;
489 Ok(ids.iter().filter(|id| lib.item(id).is_some()).count())
490}
491
492pub fn paths_text(lib: &Library, items: &[&Item]) -> String {
498 items.iter().map(|i| i.original_path(lib.root()).display().to_string()).collect::<Vec<_>>().join("\n")
499}
500
501pub fn folder_paths_text(lib: &Library, items: &[&Item]) -> String {
504 items.iter().map(|i| format!("{}{}", i.info_dir(lib.root()).display(), std::path::MAIN_SEPARATOR)).collect::<Vec<_>>().join("\n")
505}
506
507fn base64_type(ext: &str) -> Option<&'static str> {
510 Some(match ext.to_lowercase().as_str() {
511 "jpg" | "jpeg" | "jpe" | "jfif" | "insp" => "jpeg",
512 "png" => "png",
513 "webp" => "webp",
514 "gif" => "gif",
515 _ => return None,
516 })
517}
518
519pub fn data_url(lib: &Library, item: &Item) -> Option<String> {
524 use base64::Engine;
525 let (bytes, kind) = match base64_type(&item.ext) {
526 Some(kind) => (fs::read(item.original_path(lib.root())).ok()?, kind),
527 None => {
528 let bytes = fs::read(item.thumbnail_path(lib.root())?).ok()?;
529 let kind = match image::guess_format(&bytes) {
530 Ok(image::ImageFormat::WebP) => "webp",
531 Ok(image::ImageFormat::Jpeg) => "jpeg",
532 _ => "png",
533 };
534 (bytes, kind)
535 }
536 };
537 Some(format!("data:image/{kind};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)))
538}
539
540pub fn thumbnail_image(lib: &Library, item: &Item) -> Option<image::RgbaImage> {
543 let path = item.thumbnail_path(lib.root()).unwrap_or_else(|| item.original_path(lib.root()));
544 crate::thumbnail::decode(&path).ok().map(|i| i.to_rgba8())
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::import::ImportOptions;
551
552 fn set(names: &[&str]) -> HashSet<String> {
553 names.iter().map(|s| s.to_lowercase()).collect()
554 }
555
556 fn names(files: &[&str], existing: &[&str], how: Collision) -> Vec<String> {
557 name_files(&files.iter().map(|s| s.to_string()).collect::<Vec<_>>(), &set(existing), how)
558 }
559
560 #[test]
561 fn unique_names_stay() {
562 assert_eq!(names(&["a.png", "b.jpg", "noext"], &["c.png"], Collision::KeepBoth), ["a.png", "b.jpg", "noext"]);
563 }
564
565 #[test]
566 fn clashes_in_the_batch_are_numbered_ignoring_case() {
567 assert_eq!(names(&["a.png", "A.PNG", "b.png"], &[], Collision::KeepBoth), ["a-1.png", "A-2.PNG", "b.png"]);
568 assert_eq!(names(&["x", "x"], &[], Collision::Replace), ["x-1", "x-2"]);
569 let ten = names(&["a.png"; 10], &[], Collision::Replace);
571 assert_eq!(ten[0], "a-01.png");
572 assert_eq!(ten[9], "a-10.png");
573 }
574
575 #[test]
576 fn existing_files_are_kept_or_replaced() {
577 assert_eq!(names(&["a.png", "b.png"], &["A.png"], Collision::KeepBoth), ["a-2.png", "b.png"]);
578 assert_eq!(names(&["a.png", "b.png"], &["a.png"], Collision::Replace), ["a.png", "b.png"]);
579 assert_eq!(names(&["a.png"], &["a.png", "a-2.png"], Collision::KeepBoth), ["a-3.png"]);
581 assert_eq!(names(&["a.png", "a.png", "a-1.png"], &[], Collision::KeepBoth), ["a-2.png", "a-3.png", "a-1.png"]);
583 }
584
585 fn png(path: &Path, shade: u8) {
586 image::RgbImage::from_fn(8, 8, |_, _| image::Rgb([shade, 0, 0])).save(path).unwrap();
587 }
588
589 fn library(root: &Path) -> (Library, Vec<String>) {
591 let mut lib = Library::create(root.join("A.library")).unwrap();
592 let mut ids = Vec::new();
593 for (i, name) in ["pic", "other", "pic"].iter().enumerate() {
594 let dir = root.join(format!("src{i}"));
595 fs::create_dir_all(&dir).unwrap();
596 let src = dir.join(format!("{name}.png"));
597 png(&src, 40 * i as u8);
598 filetime::set_file_mtime(&src, FileTime::from_unix_time(1_600_000_000 + i as i64, 0)).unwrap();
599 ids.push(lib.import_file(&src, &ImportOptions { palette: false, ..Default::default() }).unwrap());
600 }
601 (lib, ids)
602 }
603
604 #[test]
605 fn items_are_copied_with_their_times() {
606 let dir = tempfile::tempdir().unwrap();
607 let (lib, ids) = library(dir.path());
608 let out = dir.path().join("out");
609 let plan = plan_items(&lib, &ids, &out, Collision::KeepBoth);
610 let to: Vec<_> = plan.steps.iter().map(|s| s.to.file_name().unwrap().to_string_lossy().into_owned()).collect();
611 assert_eq!(to, ["pic-1.png", "other.png", "pic-2.png"]);
612 assert_eq!(plan.clashes(), 0);
613 let mut seen = 0;
614 let written = run(&plan, &AtomicBool::new(false), |_, ok| seen += usize::from(ok)).unwrap();
615 assert_eq!((written.len(), seen), (3, 3));
616 for (step, id) in plan.steps.iter().zip(&ids) {
617 let item = lib.item(id).unwrap();
618 assert_eq!(fs::read(&step.to).unwrap(), fs::read(item.original_path(lib.root())).unwrap());
619 let a = FileTime::from_last_modification_time(&fs::metadata(&step.to).unwrap());
620 let b = FileTime::from_last_modification_time(&fs::metadata(item.original_path(lib.root())).unwrap());
621 assert_eq!(a.unix_seconds(), b.unix_seconds());
622 }
623 assert_eq!(fs::read_dir(&out).unwrap().count(), 3);
625 }
626
627 #[test]
628 fn a_cancelled_export_stops_between_files_and_keeps_what_it_wrote() {
629 let dir = tempfile::tempdir().unwrap();
630 let (lib, ids) = library(dir.path());
631 let out = dir.path().join("out");
632 let plan = plan_items(&lib, &ids, &out, Collision::KeepBoth);
633 let cancel = AtomicBool::new(false);
634 let mut steps = 0;
635 let written = run(&plan, &cancel, |_, _| {
636 steps += 1;
637 cancel.store(true, Ordering::Relaxed);
638 })
639 .unwrap();
640 assert_eq!((written.len(), steps), (1, 1), "stops after the file in progress");
641 assert_eq!(fs::read_dir(&out).unwrap().count(), 1, "the written file stays, no temp file");
642 let cancel = AtomicBool::new(true);
643 let convert = Convert { format: Format::Png, quality: 90, longest: None };
644 let conv = plan_converted(&lib, &ids, &dir.path().join("conv"), Format::Png, Collision::KeepBoth);
645 assert!(run_converted(&conv, &convert, &cancel, |_, _| panic!("no step after cancel")).unwrap().is_empty());
646 }
647
648 #[test]
649 fn a_second_export_keeps_both_or_replaces() {
650 let dir = tempfile::tempdir().unwrap();
651 let (lib, ids) = library(dir.path());
652 let out = dir.path().join("out");
653 fs::create_dir_all(&out).unwrap();
654 fs::write(out.join("other.png"), b"mine").unwrap();
655
656 let replace = plan_items(&lib, &ids[1..2], &out, Collision::Replace);
657 assert_eq!(replace.clashes(), 1);
658 let keep = plan_items(&lib, &ids[1..2], &out, Collision::KeepBoth);
659 assert_eq!(keep.steps[0].to, out.join("other-2.png"));
660 run(&keep, &AtomicBool::new(false), |_, _| {}).unwrap();
661 assert_eq!(fs::read(out.join("other.png")).unwrap(), b"mine");
662 run(&replace, &AtomicBool::new(false), |_, _| {}).unwrap();
663 assert_ne!(fs::read(out.join("other.png")).unwrap(), b"mine");
664 assert_eq!(fs::read(out.join("other.png")).unwrap(), fs::read(out.join("other-2.png")).unwrap());
665 }
666
667 #[test]
668 fn folder_names_are_cleaned_like_eagle() {
669 assert_eq!(folder_dir_name(" a\tb c "), "a b c");
670 assert_eq!(folder_dir_name("x/y:z\\w"), "x y z w");
671 assert_eq!(folder_dir_name("dots..."), "dots");
672 assert_eq!(folder_dir_name(" / "), "Untitled");
673 }
674
675 #[test]
676 fn folders_export_as_a_tree() {
677 let dir = tempfile::tempdir().unwrap();
678 let (mut lib, ids) = library(dir.path());
679 let top = lib.create_folder(None, "Refs: old").unwrap();
680 let child = lib.create_folder(Some(&top), "Inner").unwrap();
681 let twin = lib.create_folder(Some(&top), "Inner").unwrap();
682 let locked = lib.create_folder(Some(&top), "Secret").unwrap();
683 let file = |lib: &mut Library, id: &str, folders: &[&String]| {
684 let mut item = lib.item(id).unwrap().clone();
685 item.folders = folders.iter().map(|f| f.to_string()).collect();
686 lib.update_item(item).unwrap();
687 };
688 file(&mut lib, &ids[0], &[&top, &child]);
689 file(&mut lib, &ids[1], &[&locked]);
690 file(&mut lib, &ids[2], &[&twin]);
691 let out = dir.path().join("out");
692 let hidden: HashSet<String> = [locked.clone()].into();
693 let plan = plan_folder(&lib, &top, &out, Collision::KeepBoth, &hidden).unwrap();
694 let root = out.join("Refs old");
695 assert_eq!(plan.top, root);
696 assert_eq!(plan.dirs, [root.clone(), root.join("Inner")]);
697 let to: Vec<_> = plan.steps.iter().map(|s| s.to.strip_prefix(&out).unwrap().to_path_buf()).collect();
698 assert_eq!(to, [PathBuf::from("Refs old/pic.png"), PathBuf::from("Refs old/Inner/pic-1.png"), PathBuf::from("Refs old/Inner/pic-2.png")]);
700 run(&plan, &AtomicBool::new(false), |_, _| {}).unwrap();
701 assert!(root.join("Inner/pic-2.png").is_file());
702
703 let plan = plan_folder(&lib, &top, &out, Collision::Replace, &HashSet::new()).unwrap();
705 assert!(plan.dirs.contains(&root.join("Secret")));
706 assert_eq!(plan.clashes(), 3);
707 assert!(plan_folder(&lib, "NOPE", &out, Collision::Replace, &HashSet::new()).is_none());
708 }
709
710 #[test]
711 fn smart_folders_export_flat() {
712 let dir = tempfile::tempdir().unwrap();
713 let (mut lib, ids) = library(dir.path());
714 let rule = serde_json::json!({"rules": [{"property": "name", "method": "contain", "value": "pic"}], "match": "AND", "boolean": "TRUE"});
715 let id = lib.create_smart_folder("Pics", vec![rule]).unwrap();
716 assert_eq!(lib.smart_folder_items(&id).len(), 2);
717 let out = dir.path().join("out");
718 let plan = plan_smart(&lib, &id, &out, Collision::KeepBoth, &HashSet::new()).unwrap();
719 assert_eq!(plan.dirs, [out.join("Pics")]);
720 let got: HashSet<_> = plan.steps.iter().map(|s| (s.id.clone(), s.to.parent().unwrap().to_path_buf())).collect();
721 assert_eq!(got, [(ids[0].clone(), out.join("Pics")), (ids[2].clone(), out.join("Pics"))].into());
722 let names: HashSet<_> = plan.steps.iter().map(|s| s.to.file_name().unwrap().to_string_lossy().into_owned()).collect();
723 assert_eq!(names, ["pic-1.png".to_string(), "pic-2.png".to_string()].into());
724 assert!(plan_smart(&lib, "NOPE", &out, Collision::KeepBoth, &HashSet::new()).is_none());
725 }
726
727 #[test]
728 fn csv_fields_are_quoted_only_when_needed() {
729 assert_eq!(csv_field("plain"), "plain");
730 assert_eq!(csv_field(""), "");
731 assert_eq!(csv_field("a, b"), "\"a, b\"");
732 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
733 assert_eq!(csv_field("two\nlines"), "\"two\nlines\"");
734 assert_eq!(csv_field("cr\r"), "\"cr\r\"");
735 assert_eq!(csv_field("日本語"), "日本語");
736 assert_eq!(csv_time(0), "");
737 }
738
739 #[test]
740 fn csv_has_a_row_per_item_in_eagles_column_order() {
741 let dir = tempfile::tempdir().unwrap();
742 let (mut lib, ids) = library(dir.path());
743 let folder = lib.create_folder(None, "Refs, old").unwrap();
744 let mut item = lib.item(&ids[1]).unwrap().clone();
745 item.tags = vec!["blue".into(), "logo".into()];
746 item.folders = vec![folder.clone(), "GONE".into()];
747 item.url = "https://example.com/a?b=1,2".into();
748 item.annotation = "line one\nline \"two\"".into();
749 item.modification_time = 1_700_000_000_123;
750 item.extra.insert("star".into(), serde_json::json!(4));
751 item.set_comments(&[crate::model::Comment::region(1, 2, 3, 4, "first"), crate::model::Comment::region(1, 2, 3, 4, "second")]);
752 lib.update_item(item).unwrap();
753
754 let out = dir.path().join("x.csv");
755 assert_eq!(write_csv(&lib, &[ids[1].clone(), "NOPE".into()], &out).unwrap(), 1);
756 let text = fs::read_to_string(&out).unwrap();
757 let body = text.strip_prefix('\u{feff}').expect("starts with a BOM");
758 assert!(!body.ends_with('\n'));
759 let (header, row) = body.split_once('\n').unwrap();
760 assert_eq!(header.split(',').count(), 16);
761 assert!(header.starts_with("Item ID,Name,File Type,"));
762 let item = lib.item(&ids[1]).unwrap();
763 let path = item.original_path(lib.root()).display().to_string();
764 let want = format!(
765 "{},other,png,8,8,,\"https://example.com/a?b=1,2\",\"line one\nline \"\"two\"\"\",\"first\nsecond\",\"blue, logo\",\"Refs, old, GONE\",{},4,2023-11-14 22:13:20,{},{}",
766 ids[1], item.size, csv_time(item.last_modified), csv_field(&path)
767 );
768 assert_eq!(row, want);
769 }
770
771 #[test]
772 fn copy_texts_and_data_urls() {
773 use base64::Engine;
774 let dir = tempfile::tempdir().unwrap();
775 let (mut lib, ids) = library(dir.path());
776 let items: Vec<&Item> = ids.iter().filter_map(|id| lib.item(id)).collect();
777 let paths = paths_text(&lib, &items);
778 assert_eq!(paths.lines().count(), 3);
779 assert!(paths.lines().next().unwrap().ends_with(&format!("{}pic.png", std::path::MAIN_SEPARATOR)));
780 let folders = folder_paths_text(&lib, &items[..1]);
781 assert_eq!(folders, format!("{}{}", items[0].info_dir(lib.root()).display(), std::path::MAIN_SEPARATOR));
782
783 let url = data_url(&lib, items[0]).unwrap();
785 let b64 = url.strip_prefix("data:image/png;base64,").unwrap();
786 let bytes = base64::engine::general_purpose::STANDARD.decode(b64).unwrap();
787 assert_eq!(bytes, fs::read(items[0].original_path(lib.root())).unwrap());
788 assert_eq!(thumbnail_image(&lib, items[0]).unwrap().dimensions(), (8, 8));
789
790 let mut other = items[1].clone();
792 let info = other.info_dir(lib.root());
793 fs::remove_file(other.original_path(lib.root())).unwrap();
794 fs::write(info.join("other.txt"), b"hello").unwrap();
795 other.ext = "txt".into();
796 lib.update_item(other.clone()).unwrap();
797 let thumb = info.join("other_thumbnail.png");
798 let webp = webp::Encoder::from_rgba(&[255; 16], 2, 2).encode(80.0).to_vec();
799 fs::write(&thumb, &webp).unwrap();
800 let other = lib.item(&ids[1]).unwrap();
801 assert!(data_url(&lib, other).unwrap().starts_with("data:image/webp;base64,"));
802 assert_eq!(thumbnail_image(&lib, other).unwrap().dimensions(), (2, 2));
803 fs::remove_file(&thumb).unwrap();
804 assert!(data_url(&lib, other).is_none());
805 assert!(thumbnail_image(&lib, other).is_none());
806 }
807
808 #[test]
809 fn images_convert_to_each_format_and_size() {
810 let dir = tempfile::tempdir().unwrap();
811 let src = dir.path().join("wide.png");
812 image::RgbaImage::from_fn(40, 20, |x, _| image::Rgba([200, 10, 10, if x < 20 { 0 } else { 255 }])).save(&src).unwrap();
813 for format in Format::ALL {
814 let convert = Convert { format, quality: 80, longest: Some(10) };
815 let bytes = convert_bytes(&src, &convert).unwrap();
816 let want = match format {
817 Format::Jpeg => image::ImageFormat::Jpeg,
818 Format::Png => image::ImageFormat::Png,
819 Format::Webp => image::ImageFormat::WebP,
820 };
821 assert_eq!(image::guess_format(&bytes).unwrap(), want);
822 let img = image::load_from_memory(&bytes).unwrap();
823 assert_eq!((img.width(), img.height()), (10, 5), "{format:?}");
824 if format == Format::Jpeg {
825 let p = img.to_rgb8().get_pixel(0, 2).0;
827 assert!(p.iter().all(|c| *c > 200), "{p:?}");
828 }
829 }
830 let bytes = convert_bytes(&src, &Convert { format: Format::Webp, quality: 100, longest: Some(400) }).unwrap();
832 let img = image::load_from_memory(&bytes).unwrap();
833 assert_eq!((img.width(), img.height()), (40, 20));
834 assert_eq!(img.to_rgba8().get_pixel(30, 3).0, [200, 10, 10, 255]);
835 assert!(convert_bytes(&dir.path().join("missing.png"), &Convert { format: Format::Png, quality: 90, longest: None }).is_err());
836 }
837
838 #[test]
839 fn converted_exports_are_renamed_and_skip_other_kinds() {
840 let dir = tempfile::tempdir().unwrap();
841 let (mut lib, ids) = library(dir.path());
842 let mut other = lib.item(&ids[1]).unwrap().clone();
843 other.ext = "mp3".into();
844 lib.update_item(other).unwrap();
845 let out = dir.path().join("out");
846 let plan = plan_converted(&lib, &ids, &out, Format::Jpeg, Collision::KeepBoth);
847 let to: Vec<_> = plan.steps.iter().map(|s| s.to.file_name().unwrap().to_string_lossy().into_owned()).collect();
848 assert_eq!(to, ["pic-1.jpg", "pic-2.jpg"]);
849 let convert = Convert { format: Format::Jpeg, quality: 90, longest: None };
850 let written = run_converted(&plan, &convert, &AtomicBool::new(false), |_, _| {}).unwrap();
851 assert_eq!(written.len(), 2);
852 assert_eq!(image::guess_format(&fs::read(&written[0]).unwrap()).unwrap(), image::ImageFormat::Jpeg);
853 let again = plan_converted(&lib, &ids[..1], &out, Format::Jpeg, Collision::KeepBoth);
855 assert_eq!(again.steps[0].to, out.join("pic.jpg"));
856 }
857
858 #[test]
859 fn failed_steps_are_skipped() {
860 let dir = tempfile::tempdir().unwrap();
861 let (lib, ids) = library(dir.path());
862 let out = dir.path().join("out");
863 let mut plan = plan_items(&lib, &ids, &out, Collision::KeepBoth);
864 plan.steps[0].from = dir.path().join("missing.png");
865 let mut failed = 0;
866 let written = run(&plan, &AtomicBool::new(false), |_, ok| failed += usize::from(!ok)).unwrap();
867 assert_eq!((written.len(), failed), (2, 1));
868 assert_eq!(fs::read_dir(&out).unwrap().count(), 2);
869 }
870}